detached ca376f7

feat(broker): route clients through autonomous broker

commit
ca376f7628379c809d4ce7099c8fd3a22d1c92f0
author
brice · · 2026-09-22 07:20 UTC
committer
brice · · 2026-09-22 07:20 UTC
parent
b56b682
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
326 files · +20457 -39680
M boundary_test.go +84 -20
@@ -2,6 +2,7 @@ package main_test
22
33 import (
44 "fmt"
5+ "go/ast"
56 "go/parser"
67 "go/token"
78 "io/fs"
@@ -87,12 +88,6 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) {
8788 }{
8889 {"usecase accepts semantic protocol", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol", false, true},
8990 {"usecase rejects wire", modulePath + "/internal/usecase/daemon", modulePath + "/internal/protocol/wire", false, false},
90- {"remotes accepts ports", modulePath + "/internal/usecase/remotes", modulePath + "/internal/ports", false, true},
91- {"remotes accepts domain", modulePath + "/internal/usecase/remotes", modulePath + "/internal/domain", false, true},
92- {"remotes rejects wire", modulePath + "/internal/usecase/remotes", modulePath + "/internal/protocol/wire", false, false},
93- {"remotes rejects adapter", modulePath + "/internal/usecase/remotes", modulePath + "/internal/adapters/ipc", false, false},
94- {"remotes rejects app", modulePath + "/internal/usecase/remotes", modulePath + "/internal/app", false, false},
95- {"remotes rejects persist", modulePath + "/internal/usecase/remotes", modulePath + "/internal/persist", false, false},
9691 {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false},
9792 {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false},
9893 {"pkg rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", false, false},
@@ -110,26 +105,17 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) {
110105 {"broker subpackage accepts broker root", modulePath + "/internal/usecase/broker/streams", modulePath + "/internal/usecase/broker", false, true},
111106 {"broker rejects client", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/client", false, false},
112107 {"broker rejects daemon", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/daemon", false, false},
113- {"broker rejects remotes", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/remotes", false, false},
114108 {"broker rejects sibling usecase", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/picker", false, false},
115109 {"broker rejects adapter", modulePath + "/internal/usecase/broker", modulePath + "/internal/adapters/ipc", false, false},
116- {"broker rejects broker adapter", modulePath + "/internal/usecase/broker", modulePath + "/internal/adapters/remote", false, false},
117110 {"broker rejects wire", modulePath + "/internal/usecase/broker", modulePath + "/internal/protocol/wire", false, false},
118111 {"broker rejects app", modulePath + "/internal/usecase/broker", modulePath + "/internal/app", false, false},
119112 {"broker rejects persist", modulePath + "/internal/usecase/broker", modulePath + "/internal/persist", false, false},
120113 // P1.1 client/daemon must not own broker, each other, or remote discovery.
121114 {"client rejects daemon", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/daemon", false, false},
122115 {"client rejects broker", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/broker", false, false},
123- {"client rejects remotes", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/remotes", false, false},
124- {"client rejects broker adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/remote", false, false},
125116 {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false},
126117 {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false},
127- {"daemon rejects remotes registry", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/remotes", false, false},
128- {"daemon rejects remote adapter", modulePath + "/internal/usecase/daemon", modulePath + "/internal/adapters/remote", false, false},
129118 {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false},
130- {"remotes rejects client", modulePath + "/internal/usecase/remotes", modulePath + "/internal/usecase/client", false, false},
131- {"remotes rejects daemon", modulePath + "/internal/usecase/remotes", modulePath + "/internal/usecase/daemon", false, false},
132- {"remotes rejects broker", modulePath + "/internal/usecase/remotes", modulePath + "/internal/usecase/broker", false, false},
133119 }
134120 for _, tt := range tests {
135121 t.Run(tt.name, func(t *testing.T) {
@@ -223,14 +209,12 @@ func dependencyAllowed(source, target string, testFile bool) (bool, error) {
223209
224210 // packageImportDenied encodes ADR 001 ownership at package granularity for
225211 // production files. Layer rules alone permit any usecase-to-usecase import;
226-// these denies keep broker, client, daemon, and remotes from owning each
227212 // other, and keep the broker free of every sibling use case. Test files stay
228213 // exempt so composition tests can wire adapters and orchestrators together.
229214 func packageImportDenied(source, target string) bool {
230215 brokerPkg := modulePath + "/internal/usecase/broker"
231216 clientPkg := modulePath + "/internal/usecase/client"
232217 daemonPkg := modulePath + "/internal/usecase/daemon"
233- remotesPkg := modulePath + "/internal/usecase/remotes"
234218 usecasePrefix := modulePath + "/internal/usecase/"
235219 under := func(path, root string) bool {
236220 return path == root || strings.HasPrefix(path, root+"/")
@@ -239,10 +223,8 @@ func packageImportDenied(source, target string) bool {
239223 case under(source, brokerPkg):
240224 return !under(target, brokerPkg) && strings.HasPrefix(target, usecasePrefix)
241225 case under(source, clientPkg):
242- return under(target, daemonPkg) || under(target, brokerPkg) || under(target, remotesPkg)
226+ return under(target, daemonPkg) || under(target, brokerPkg)
243227 case under(source, daemonPkg):
244- return under(target, clientPkg) || under(target, brokerPkg) || under(target, remotesPkg)
245- case under(source, remotesPkg):
246228 return under(target, clientPkg) || under(target, daemonPkg) || under(target, brokerPkg)
247229 default:
248230 return false
@@ -371,3 +353,85 @@ func inspectExternalDependencies(t *testing.T) []string {
371353 sort.Strings(violations)
372354 return violations
373355 }
356+
357+// TestProductionDaemonBindingComposition pins the daemon authority composition
358+// at the source level: the daemon's process binding may only be built by the one
359+// production site that provisions the daemon's closed policy set, and the
360+// single-policy shorthand must not reappear outside test fixtures.
361+//
362+// The multi-policy constructor exists because a daemon serves one exact policy
363+// per accepted carriage shape (its own local Unix daemonmux policy, plus each
364+// provisioned remote transport policy). A production composition that used the
365+// single-policy shorthand would publish an authority that refuses every remote
366+// carriage, so the composition is pinned to the multi-policy call, and the
367+// shorthand stays a fixtures-only convenience.
368+func TestProductionDaemonBindingComposition(t *testing.T) {
369+ const runFile = "internal/app/run.go"
370+
371+ var (
372+ bindingSites []string
373+ runBindings int
374+ )
375+ fset := token.NewFileSet()
376+ err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
377+ if walkErr != nil {
378+ return walkErr
379+ }
380+ name := entry.Name()
381+ if entry.IsDir() {
382+ if name == ".git" || name == ".worktrees" || name == "vendor" {
383+ return filepath.SkipDir
384+ }
385+ return nil
386+ }
387+ if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
388+ return nil
389+ }
390+ file, err := parser.ParseFile(fset, path, nil, 0)
391+ if err != nil {
392+ return err
393+ }
394+ slashed := filepath.ToSlash(path)
395+ for _, decl := range file.Decls {
396+ fn, ok := decl.(*ast.FuncDecl)
397+ if !ok || fn.Body == nil {
398+ continue
399+ }
400+ ast.Inspect(fn.Body, func(node ast.Node) bool {
401+ call, ok := node.(*ast.CallExpr)
402+ if !ok {
403+ return true
404+ }
405+ selector, ok := call.Fun.(*ast.SelectorExpr)
406+ if !ok {
407+ return true
408+ }
409+ // Only the adapter's own constructors count: a method spelled
410+ // the same way elsewhere is unrelated.
411+ ident, ok := selector.X.(*ast.Ident)
412+ if !ok || ident.Name != "daemonmux" {
413+ return true
414+ }
415+ switch selector.Sel.Name {
416+ case "NewServerBinding":
417+ bindingSites = append(bindingSites, fmt.Sprintf("%s:%d: %s must build the closed policy set with NewServerBindings", slashed, fset.Position(call.Pos()).Line, selector.Sel.Name))
418+ case "NewServerBindings":
419+ if slashed == runFile {
420+ runBindings++
421+ }
422+ }
423+ return true
424+ })
425+ }
426+ return nil
427+ })
428+ if err != nil {
429+ t.Fatalf("walking repository: %v", err)
430+ }
431+ if len(bindingSites) != 0 {
432+ t.Fatalf("production code uses the single-policy daemon binding shorthand:\n%s", strings.Join(bindingSites, "\n"))
433+ }
434+ if runBindings == 0 {
435+ t.Fatalf("%s must compose the daemon authority with daemonmux.NewServerBindings", runFile)
436+ }
437+}
M cmd/vev-perf-harness/evidence_test.go +1 -1
@@ -34,7 +34,7 @@ func TestCLITransportSeamOwnsExclusivePeerTraceAndCleanup(t *testing.T) {
3434 t.Fatal(err)
3535 }
3636 text := string(shim)
37- for _, want := range []string{"_stdio", m.TracePath, m.ProcessID} {
37+ for _, want := range []string{"_broker-mux-stdio", m.TracePath, m.ProcessID} {
3838 if !strings.Contains(text, want) {
3939 t.Errorf("seam does not retain %q:\n%s", want, text)
4040 }
M cmd/vev-perf-harness/hangup_test.go +8 -8
@@ -110,11 +110,11 @@ func TestClientTracePairsAfterPTYHangup(t *testing.T) {
110110 }
111111 }()
112112
113- // Wait until the client is attached with a receive pump blocked mid-frame:
114- // its trace then holds one more adapter_receive_start than end. That in-flight
115- // span is exactly what an ungraceful death truncates.
116- if err := waitForInflightReceive(clientMapping.TracePath, 10*time.Second); err != nil {
117- t.Fatalf("client never reached a blocked receive: %v", err)
113+ // Wait until the broker-backed client reaches a real attached shell before
114+ // severing its terminal. Session carriage tracing now belongs to the broker,
115+ // not the thin client process.
116+ if err := client.waitForTerminalReady(); err != nil {
117+ t.Fatalf("client never reached an attached shell: %v", err)
118118 }
119119
120120 // Sever the controlling terminal with no graceful exit. This is the harness
@@ -183,7 +183,7 @@ func TestClientGracefulShutdownDrainsTracedStdioDescendant(t *testing.T) {
183183 var command roleCommand
184184 switch pm.Role {
185185 case "daemon":
186- command = roleCommand{Args: []string{"--daemon"}}
186+ command = routeRoleArgs(s, pm, transport{})
187187 case "ssh_stdio_peer":
188188 // The transport-neutral _stdio mode requests an ephemeral session.
189189 command = roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}}
@@ -231,8 +231,8 @@ func TestClientGracefulShutdownDrainsTracedStdioDescendant(t *testing.T) {
231231 }
232232 }()
233233
234- if err := waitForInflightReceive(peerMapping.TracePath, 10*time.Second); err != nil {
235- t.Fatalf("stdio descendant never reached blocked receive: %v", err)
234+ if err := client.waitForTerminalReady(); err != nil {
235+ t.Fatalf("stdio client never reached an attached shell: %v", err)
236236 }
237237 processGroup := client.cmd.Process.Pid
238238 if err := client.Close(); err != nil {
M cmd/vev-perf-harness/launcher.go +200 -50
@@ -5,6 +5,7 @@ package main
55 import (
66 "bytes"
77 "context"
8+ "encoding/json"
89 "errors"
910 "fmt"
1011 "io"
@@ -66,8 +67,10 @@ type cliLauncher struct {
6667 }
6768
6869 type peerRoute struct {
69- mapping processMapping
70- command roleCommand
70+ mapping processMapping
71+ command roleCommand
72+ runtimeDir string
73+ stateDir string
7174 }
7275
7376 type preparedPeer struct {
@@ -91,6 +94,46 @@ type terminalOutput interface {
9194 Close() error
9295 }
9396
97+type capturedTerminalOutput struct {
98+ file *os.File
99+ mu sync.Mutex
100+ data bytes.Buffer
101+}
102+
103+type synchronizedBuffer struct {
104+ mu sync.Mutex
105+ data bytes.Buffer
106+}
107+
108+func (b *synchronizedBuffer) Write(p []byte) (int, error) {
109+ b.mu.Lock()
110+ defer b.mu.Unlock()
111+ return b.data.Write(p)
112+}
113+
114+func (b *synchronizedBuffer) WriteString(value string) {
115+ _, _ = b.Write([]byte(value))
116+}
117+
118+func (b *synchronizedBuffer) String() string {
119+ b.mu.Lock()
120+ defer b.mu.Unlock()
121+ return b.data.String()
122+}
123+
124+func (o *capturedTerminalOutput) Write(p []byte) (int, error) {
125+ o.mu.Lock()
126+ _, _ = o.data.Write(p)
127+ o.mu.Unlock()
128+ return o.file.Write(p)
129+}
130+func (o *capturedTerminalOutput) Close() error { return o.file.Close() }
131+func (o *capturedTerminalOutput) String() string {
132+ o.mu.Lock()
133+ defer o.mu.Unlock()
134+ return o.data.String()
135+}
136+
94137 var setPTYWinsize = rawterm.SetWinsize
95138
96139 type cliProcess struct {
@@ -108,7 +151,12 @@ type cliProcess struct {
108151 shutdown func() error
109152 cleanupRuntime func() error
110153 terminalReady bool
111- readyPath string
154+ readyCommand *exec.Cmd
155+ startCommand *exec.Cmd
156+ configureCommand *exec.Cmd
157+ serverOutput *synchronizedBuffer
158+ peerCommand *exec.Cmd
159+ peerDone chan error
112160 closeResult error
113161 }
114162
@@ -146,25 +194,80 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
146194 // a role working directory: daemon-owned subprocess cleanup may otherwise
147195 // treat the evidence directory as its working tree and remove a preallocated
148196 // trace while a later repetition is being merged.
149- 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"), 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", "VEV_REMOTE_TRANSPORT", "XDG_RUNTIME_DIR", "XDG_STATE_HOME", "XDG_CONFIG_HOME"), traceEnvironment(m)...)
150198 cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir,
151- "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color", "VEV_PERF_BIN="+bin)
152- if m.Role == "client" {
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()
201+ peer, routed := l.peers[runDir]
202+ l.mu.Unlock()
203+ if routed && (m.Role == "client" || m.Role == "daemon") {
204+ mode, err := remoteMode(peer.command.Transport)
205+ if err != nil {
206+ return nil, err
207+ }
208+ cmd.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode)
209+ }
210+ if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
211+ return nil, err
212+ }
213+ if m.Role == "daemon" {
214+ configDir := filepath.Join(runDir, "config", "vev")
215+ if err := safedir.EnsurePrivate(configDir); err != nil {
216+ return nil, err
217+ }
218+ if err := os.WriteFile(filepath.Join(configDir, "broker.json"), []byte(`{"marker":"vev.broker.offline/v1","registrations":[]}`), 0o600); err != nil {
219+ return nil, err
220+ }
221+ }
222+ p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1), serverOutput: &synchronizedBuffer{}}
223+ if m.Role == "daemon" {
224+ p.startCommand = exec.Command(bin, "ls")
225+ p.startCommand.Env = cmd.Env
226+ p.readyCommand = exec.Command(bin, "_broker-ready", "--require", "local-catalogue", "--timeout", "10s")
227+ p.readyCommand.Env = cmd.Env
153228 l.mu.Lock()
154229 peer, routed := l.peers[runDir]
155230 l.mu.Unlock()
156231 if routed {
157- mode, err := remoteMode(peer.command.Transport)
158- if err != nil {
159- return nil, err
232+ mode, modeErr := remoteMode(peer.command.Transport)
233+ if modeErr != nil {
234+ return nil, modeErr
160235 }
161- cmd.Env = append(cmd.Env, "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode)
236+ p.configureCommand = exec.Command(bin, "host", "add", "harness@127.0.0.1")
237+ p.configureCommand.Env = append(withoutEnv(cmd.Env, "PATH", "VEV_REMOTE_TRANSPORT"), "PATH="+runDir+":"+os.Getenv("PATH"), "VEV_REMOTE_TRANSPORT="+mode)
238+ peerStart := exec.Command(bin, "--daemon")
239+ 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+ "PATH="+runDir+":"+os.Getenv("PATH"),
241+ "XDG_RUNTIME_DIR="+peer.runtimeDir,
242+ "XDG_STATE_HOME="+peer.stateDir,
243+ "TERM=xterm-256color")
244+ peerStartOutput := &bytes.Buffer{}
245+ peerStart.Stdin, peerStart.Stdout, peerStart.Stderr = nil, peerStartOutput, peerStartOutput
246+ if err := peerStart.Start(); err != nil {
247+ return nil, fmt.Errorf("start remote peer daemon: %w", err)
248+ }
249+ peerSocket := filepath.Join(peer.runtimeDir, "vev", "daemonmux.sock")
250+ deadline := time.Now().Add(10 * time.Second)
251+ for {
252+ if info, err := os.Stat(peerSocket); err == nil && info.Mode()&os.ModeSocket != 0 {
253+ break
254+ }
255+ if time.Now().After(deadline) {
256+ return nil, fmt.Errorf("remote peer daemon did not publish %s (output=%q)", peerSocket, peerStartOutput.String())
257+ }
258+ time.Sleep(10 * time.Millisecond)
259+ }
260+ p.peerCommand = peerStart
261+ p.peerDone = make(chan error, 1)
262+ go func() {
263+ err := peerStart.Wait()
264+ if err != nil {
265+ p.serverOutput.WriteString(" remote-peer: " + err.Error() + " " + peerStartOutput.String())
266+ }
267+ p.peerDone <- err
268+ }()
162269 }
163270 }
164- if err := safedir.EnsurePrivate(filepath.Join(runDir, "state")); err != nil {
165- return nil, err
166- }
167- p := &cliProcess{cmd: cmd, waitErr: make(chan error, 1), readyPath: filepath.Join(runtimeDir, "vev", "daemon.sock")}
168271 if m.Role == "daemon" {
169272 p.cleanupRuntime = func() error { return l.releaseRuntime(runDir) }
170273 }
@@ -197,7 +300,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
197300 return nil, err
198301 }
199302 _ = slave.Close()
200- p.pty, p.output, p.chunks, p.done = master, output, make(chan []byte, 32), make(chan struct{})
303+ p.pty, p.output, p.chunks, p.done = master, &capturedTerminalOutput{file: output}, make(chan []byte, 32), make(chan struct{})
201304 // Stop workload generation through the attached shell before any signal.
202305 // A normal shell exit makes the daemon detach the client; the client then
203306 // closes and waits for its ssh transport, allowing a traced _stdio
@@ -213,7 +316,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
213316 if err != nil {
214317 return nil, err
215318 }
216- cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
319+ cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, p.serverOutput, p.serverOutput
217320 if err := cmd.Start(); err != nil {
218321 _ = devNull.Close()
219322 if p.cleanupRuntime != nil {
@@ -224,9 +327,9 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
224327 _ = devNull.Close()
225328 }
226329 if m.Role == "daemon" {
227- // Stop the foreground daemon through its public CLI before its bounded
228- // process-group fallback. This lets blocked carriage operations close and
229- // serialize their failed end marks instead of being cut off by SIGKILL.
330+ // Ask the daemon owned by the foreground broker to stop before the
331+ // broker's bounded process-group fallback. This lets blocked carriage
332+ // operations serialize their failed end marks before either process exits.
230333 p.shutdown = func() error {
231334 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
232335 defer cancel()
@@ -310,7 +413,13 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP
310413 if len(role.Args) != 1 || role.Args[0] != "_stdio" {
311414 return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args))
312415 }
313- route := peerRoute{mapping: m, command: role}
416+ route := peerRoute{mapping: m, command: role, runtimeDir: filepath.Join(runDir, "peer-runtime"), stateDir: filepath.Join(runDir, "peer-state")}
417+ if err := safedir.EnsurePrivate(route.runtimeDir); err != nil {
418+ return nil, fail(err)
419+ }
420+ if err := safedir.EnsurePrivate(route.stateDir); err != nil {
421+ return nil, fail(err)
422+ }
314423 shim := filepath.Join(runDir, "ssh")
315424 // The SSH command seam forwards the public _stdio entrypoint with the
316425 // peer's unique trace identity, so the peer carrying client traffic is the
@@ -318,8 +427,8 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP
318427 body := fmt.Sprintf(`#!/bin/sh
319428 set -eu
320429 case "$*" in
321- *"_stdio"*)
322- exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[6]q VEV_PERF_RUN=%[7]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _stdio
430+ *"_broker-mux-stdio"*)
431+ exec env VEV_PERF_TRACE=%[1]q VEV_PERF_PROCESS_ID=%[2]q VEV_PERF_SCENARIO=%[6]q VEV_PERF_RUN=%[7]d XDG_RUNTIME_DIR=%[3]q XDG_STATE_HOME=%[4]q TERM=xterm-256color %[5]q _broker-mux-stdio --production --daemon-start existing-only
323432 ;;
324433 *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;
325434 esac
@@ -402,35 +511,48 @@ func (p *cliProcess) copyTerminal() {
402511 }
403512 }
404513
405-// WaitReady confirms the explicitly launched public daemon owns its unique
406-// socket before a client is launched. Without this barrier, the normal client
407-// auto-spawn behavior can create an unmanifested daemon process during startup.
514+// WaitReady executes the production dial-only readiness contract under the
515+// exact XDG environment shared by the servers and clients. Its bounded JSON
516+// stdout, stderr and owner-process death are retained in any failure.
408517 func (p *cliProcess) WaitReady() error {
409- if p.readyPath == "" {
410- return errors.New("daemon readiness path is empty")
411- }
412- ticker := time.NewTicker(10 * time.Millisecond)
413- defer ticker.Stop()
414- timer := time.NewTimer(10 * time.Second)
415- defer timer.Stop()
416- for {
417- if info, err := os.Stat(p.readyPath); err == nil {
418- if info.Mode()&os.ModeSocket == 0 {
419- return fmt.Errorf("daemon readiness path %s is not a socket", p.readyPath)
420- }
421- return nil
518+ if p.startCommand != nil {
519+ // The first control stream is only a start authorization. The daemon may
520+ // close that stream while publishing its first catalogue, so readiness is
521+ // decided exclusively by the semantic probe below.
522+ _, _ = p.startCommand.CombinedOutput()
523+ }
524+ if p.readyCommand == nil {
525+ return errors.New("broker readiness command is empty")
526+ }
527+ var stdout, stderr bytes.Buffer
528+ p.readyCommand.Stdout, p.readyCommand.Stderr = &stdout, &stderr
529+ probeDone := make(chan error, 1)
530+ if err := p.readyCommand.Start(); err != nil {
531+ return fmt.Errorf("start broker readiness probe: %w", err)
532+ }
533+ go func() { probeDone <- p.readyCommand.Wait() }()
534+ select {
535+ case err := <-probeDone:
536+ if err != nil {
537+ return fmt.Errorf("broker readiness probe: %w (stdout=%q stderr=%q)", err, stdout.String(), stderr.String())
422538 }
423- select {
424- case err := <-p.waitErr:
425- p.waitErr <- err
426- if err == nil {
427- return errors.New("daemon exited before readiness")
539+ var report struct {
540+ Status string `json:"status"`
541+ }
542+ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &report); err != nil || report.Status != "ready" {
543+ return fmt.Errorf("broker readiness invalid JSON/status: %v (stdout=%q stderr=%q)", err, stdout.String(), stderr.String())
544+ }
545+ if p.configureCommand != nil {
546+ if output, err := p.configureCommand.CombinedOutput(); err != nil {
547+ return fmt.Errorf("configure remote broker membership: %w (output=%q)", err, output)
428548 }
429- return fmt.Errorf("daemon exited before readiness: %w", err)
430- case <-ticker.C:
431- case <-timer.C:
432- return fmt.Errorf("timed out waiting for daemon socket %s", p.readyPath)
433549 }
550+ return nil
551+ case err := <-p.waitErr:
552+ p.waitErr <- err
553+ _ = p.readyCommand.Process.Kill()
554+ probeErr := <-probeDone
555+ return fmt.Errorf("production server group died before readiness: %v (probe=%v stdout=%q stderr=%q servers=%q)", err, probeErr, stdout.String(), stderr.String(), p.serverOutput.String())
434556 }
435557 }
436558
@@ -483,16 +605,20 @@ func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error
483605 func (p *cliProcess) waitForTerminalReady() error {
484606 timer := time.NewTimer(10 * time.Second)
485607 defer timer.Stop()
608+ var observed []byte
486609 for {
487610 select {
488611 case chunk, ok := <-p.chunks:
489612 if !ok {
490613 return p.terminalClosedError("readiness output")
491614 }
492- if len(chunk) == 0 {
493- continue
615+ observed = append(observed, chunk...)
616+ if bytes.Contains(observed, []byte("$")) || bytes.Contains(observed, []byte("#")) {
617+ return nil
618+ }
619+ if len(observed) > 64*1024 {
620+ observed = append([]byte(nil), observed[len(observed)-32*1024:]...)
494621 }
495- return nil
496622 case err := <-p.waitErr:
497623 // Keep the exit result available to Close, which owns reaping.
498624 p.waitErr <- err
@@ -501,11 +627,21 @@ func (p *cliProcess) waitForTerminalReady() error {
501627 }
502628 return fmt.Errorf("client exited before readiness output: %w", err)
503629 case <-timer.C:
630+ if captured, ok := p.output.(*capturedTerminalOutput); ok {
631+ return fmt.Errorf("timed out waiting for client readiness output (terminal=%q)", tailString(captured.String(), 4096))
632+ }
504633 return errors.New("timed out waiting for client readiness output")
505634 }
506635 }
507636 }
508637
638+func tailString(value string, limit int) string {
639+ if len(value) <= limit {
640+ return value
641+ }
642+ return value[len(value)-limit:]
643+}
644+
509645 func (p *cliProcess) terminalClosedError(boundary string) error {
510646 if p.waitErr != nil {
511647 select {
@@ -541,6 +677,9 @@ func (p *cliProcess) waitForTerminalMarker(marker, input []byte) error {
541677 }
542678 return fmt.Errorf("client exited before application output: %w", err)
543679 case <-timer.C:
680+ if captured, ok := p.output.(*capturedTerminalOutput); ok {
681+ return fmt.Errorf("timed out waiting for application terminal output (terminal=%q)", captured.String())
682+ }
544683 return errors.New("timed out waiting for application terminal output")
545684 }
546685 }
@@ -670,6 +809,17 @@ func (p *cliProcess) Close() error {
670809 if drainDone != nil {
671810 <-drainDone
672811 }
812+ if p.peerCommand != nil && p.peerCommand.Process != nil {
813+ _ = p.peerCommand.Process.Signal(syscall.SIGTERM)
814+ if p.peerDone != nil {
815+ select {
816+ case <-p.peerDone:
817+ case <-time.After(2 * time.Second):
818+ _ = p.peerCommand.Process.Kill()
819+ <-p.peerDone
820+ }
821+ }
822+ }
673823 if p.output != nil {
674824 if err := p.output.Close(); p.closeResult == nil {
675825 p.closeResult = err
M cmd/vev-perf-harness/launcher_test.go +4 -15
@@ -4,7 +4,6 @@ package main
44
55 import (
66 "bytes"
7- "context"
87 "errors"
98 "fmt"
109 "net"
@@ -337,19 +336,9 @@ func TestCLIProcessCloseEscalationOutcome(t *testing.T) {
337336 }
338337 }
339338
340-func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) {
341- path := filepath.Join(t.TempDir(), "daemon.sock")
342- var lc net.ListenConfig
343- listener, err := lc.Listen(context.Background(), "unix", path)
344- if err != nil {
345- t.Fatal(err)
346- }
347- t.Cleanup(func() {
348- if err := listener.Close(); err != nil {
349- t.Error(err)
350- }
351- })
352- if err := (&cliProcess{readyPath: path, waitErr: make(chan error, 1)}).WaitReady(); err != nil {
339+func TestCLIProcessWaitReadyRunsJSONProbe(t *testing.T) {
340+ command := exec.Command("sh", "-c", `printf '%s\n' '{"schema":"vev.broker-ready/v1","status":"ready"}'`)
341+ if err := (&cliProcess{readyCommand: command, waitErr: make(chan error, 1)}).WaitReady(); err != nil {
353342 t.Fatal(err)
354343 }
355344 }
@@ -612,7 +601,7 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
612601 role string
613602 want []string
614603 }{
615- {"daemon", []string{"--daemon"}},
604+ {"daemon", []string{"_broker-production-serve"}},
616605 {"client", []string{"new", "perf-s-001"}},
617606 {"ssh_stdio_peer", []string{"_stdio"}},
618607 } {
M cmd/vev-perf-harness/runner.go +7 -3
@@ -387,7 +387,9 @@ func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand
387387 remote := []string{"attach", "harness@127.0.0.1"}
388388 switch m.Role {
389389 case "daemon":
390- return roleCommand{Args: []string{"--daemon"}}
390+ // The historical role now owns the foreground broker. The broker starts
391+ // and reaches the local daemon only through its daemonmux route.
392+ return roleCommand{Args: []string{"_broker-production-serve"}}
391393 case "client":
392394 switch s.Transport {
393395 case "local":
@@ -423,9 +425,11 @@ func launchOrder(mappings []processMapping) []processMapping {
423425 out := append([]processMapping(nil), mappings...)
424426 priority := func(role string) int {
425427 switch role {
426- case "daemon":
427- return 0
428428 case "ssh_stdio_peer":
429+ // Peer preparation installs the per-run SSH shim before the broker is
430+ // configured and probed; it does not start the remote process yet.
431+ return 0
432+ case "daemon":
429433 return 1
430434 case "client":
431435 return 2
M cmd/vev-perf-harness/runner_test.go +9 -4
@@ -102,8 +102,13 @@ func TestHarnessCanonicalLocalRolesAreIsolatedAcrossRepetitions(t *testing.T) {
102102 }
103103 }
104104 }
105- if _, err := os.Stat(filepath.Join(runDir, "runtime", "vev", "daemon.sock")); !errors.Is(err, os.ErrNotExist) {
106- t.Fatalf("run %d left daemon socket for the next repetition: %v", run, err)
105+ for _, socket := range []string{
106+ filepath.Join(runDir, "runtime", "vev", "broker", "broker.sock"),
107+ filepath.Join(runDir, "runtime", "vev", "daemonmux.sock"),
108+ } {
109+ if _, err := os.Stat(socket); !errors.Is(err, os.ErrNotExist) {
110+ t.Fatalf("run %d left production socket %q for the next repetition: %v", run, socket, err)
111+ }
107112 }
108113 }
109114 }
@@ -276,10 +281,10 @@ func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) {
276281 if _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, s, 1, raw); err != nil {
277282 t.Fatal(err)
278283 }
279- if len(l.mappings) != 3 || l.mappings[1].Role != tc.peer || l.mappings[2].Role != "client" {
284+ if len(l.mappings) != 3 || l.mappings[0].Role != tc.peer || l.mappings[1].Role != "daemon" || l.mappings[2].Role != "client" {
280285 t.Fatalf("dependency launch order=%+v", l.mappings)
281286 }
282- if l.commands[1].Args[0] != tc.peerCommand || l.commands[2].Args[0] != "attach" {
287+ if l.commands[0].Args[0] != tc.peerCommand || l.commands[2].Args[0] != "attach" {
283288 t.Fatalf("commands do not connect client through declared peer: %+v", l.commands)
284289 }
285290 for i, p := range l.process {
M docs/architecture.md +116 -96
@@ -64,10 +64,10 @@ Every result retains the request ID for strict correlation. Version negotiation
6464 composed only by the hidden offline broker entry points (`_broker-serve`,
6565 `_broker-launcher`, and `_broker-status`).
6666 - `internal/adapters/uidriver`: owns strict JSONL decoding, response serialization, bounded controller queues, private Unix sockets, peer credentials, and the stdio bridge. It consumes `ports.UIService` and never creates an attachment.
67-- `internal/adapters/webterm`: owns the authenticated loopback HTTP/WebSocket frontend, browser event encoding, VT-backed terminal and transactional HTML output. It implements `ports.Terminal`; application composition supplies the ordinary client runner.
67+- `internal/adapters/webterm`: owns the authenticated loopback HTTP/WebSocket frontend, browser event encoding, VT-backed terminal and transactional HTML output. It implements `ports.Terminal`; application composition supplies the ordinary client `Supervisor`.
6868 - `internal/adapters/uiterm`: owns the immutable VT mirror/snapshot and deterministic headless terminal. The concrete terminal writer (`adapters/term`) taps successful writes and flushes into this sink only when observation is enabled.
6969 - `internal/adapters`: IPC, QUIC, SSH stdio, PTY, terminal, VT-backed UI terminal/observation, JSONL UI-driver sockets, persistence-facing, broker-wire, and observability implementations. `streamframe` owns the shared 4-byte big-endian length framing of one complete Protobuf envelope used by every stream carriage.
70-- `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, injects typed ports into use cases, and owns explicit UI-driver endpoint launch configuration.
70+- `internal/app`: CLI parsing and composition. It selects local or remote carriage, wraps raw dialers with `sessionwire`, and injects typed ports into use cases. The UI driver keeps no endpoint authority of its own: it composes the process' production broker connector and delegates every transport, daemon, and session decision to the broker.
7171 - `pkg`: reusable packages that never import `internal`.
7272
7373 ## Dependency direction
@@ -86,16 +86,18 @@ main → app → usecase → ports
8686
8787 ## UI observation and control
8888
89-Headless UI-driver mode composes one normal `client.Runner`, one `uiterm` terminal, one `client.UI` service, and one bounded JSONL adapter. It does not bypass the palette, input scanners, foreground leases, navigation handoffs, output replay chain, or render publication fences. EOF cancels only that driver and detaches the attachment; it does not kill a session shell.
89+Headless UI-driver mode composes one normal `client.Supervisor`, one `uiterm` terminal, one `client.UI` service, and one bounded JSONL adapter. The supervisor obtains snapshots and opens every attachment through the single `ports.BrokerService` façade; the client does not select a daemon carriage or construct a remote registry. It does not bypass the picker, input scanners, foreground leases, navigation handoffs, output replay chain, or render publication fences. EOF cancels only that driver and detaches the attachment; it does not kill a session shell.
9090
9191 Interactive observation is a separate opt-in composition. `term.Terminal` remains the owner of raw mode, physical geometry, terminal queries, serialized output writes, and flush success. Its optional observation sink mirrors byte prefixes and successful flush boundaries into `uiterm`; UI transaction methods are delegated through a composition wrapper so semantic context is committed atomically with the physical flush. A private per-client Unix endpoint uses the same `ports.UIService`; `--ui-control` is the only mode that permits input operations. Slow or disconnected observers cannot claim geometry or block the physical terminal.
9292
9393 ## Browser terminal
9494
95-`--web-daemon` launches a separate gateway with an inherited HTTP listener, loopback by default. App composition resolves `web.listen`/`web.origin` and CLI overrides before binding. The web adapter validates the configured browser-facing Host and Origin without trusting forwarded headers; a proxy owns TLS. The same-user local control socket returns the running gateway's settings and memory-only credential; readiness probes contact only its local listener. Each authenticated WebSocket owns a normal client runner and a `webterm.Terminal`. The adapter encodes browser events as terminal input, mirrors flushed client output through `vev-vt`, and sends transactional HTML updates. Disconnect cancels only that attachment. The multiplexer remains daemon-owned; browser input does not weaken the restricted UI-driver automation contract.
95+`--web-daemon` launches a separate gateway with an inherited HTTP listener, loopback by default. App composition resolves `web.listen`/`web.origin` and CLI overrides before binding. The web adapter validates the configured browser-facing Host and Origin without trusting forwarded headers; a proxy owns TLS. The same-user local control socket returns the running gateway's settings and memory-only credential; readiness probes contact only its local listener. Each authenticated WebSocket owns a normal client `Supervisor` and a `webterm.Terminal`. The adapter encodes browser events as terminal input, mirrors flushed client output through `vev-vt`, and sends transactional HTML updates. Disconnect cancels only that attachment. The multiplexer remains daemon-owned; browser input does not weaken the restricted UI-driver automation contract.
9696
9797 ## Client-owned picker presentation
9898
99+A client publishes exactly one of three presentations at a time. `picker` is the local picker: the client is usable and describable without a broker and without a daemon. `connecting` is an attachment attempt that has not committed its first frame. `attached` is a committed attachment, and only it carries the validated session identity, the committed output boundary, and the real actionable generation; `picker` and `connecting` carry the run's stable service handle and their status with every session field zeroed, so a capture can never present session metadata as an attachment and no caller can fence an input action against a generation no attachment committed. The former `detached`, `reconnecting`, and `transitioning` values are removed without alias: a reconnect is `connecting`, a detach returns to `picker`, and terminating closes the UI service instead of becoming a fourth persistent presentation. The presentation is published inside the terminal's UI output transaction by its owner: the supervisor publishes `connecting` before the attachment foreground is granted and `picker` after the run drained and revoked, while the admitted foreground publishes `attached` only after its first frame was written, flushed, and committed.
100+
99101 Every attachment presents the navigation picker itself. The serving daemon publishes a typed interaction instead of installing an overlay: `PickerOffer` names the intent and the acquisition barrier, `PickerSnapshot` carries opaque row keys plus display text, `PickerSelection` commits a key at the displayed revision, `PickerClose` retires the interaction in either direction, and `PickerFailure` reports a bounded rejection. The daemon owns no presentation model: it keeps facts, eligibility, and mutations, and resolves committed keys to navigation, move, or kill targets.
100102
101103 Source eligibility travels with every row. `PickerLine.Focusable` is the source's authorisation to rest the cursor on that row, while `PickerLine.Actions` is what the row may commit; an action row is always focusable, a section header never is, and a focusable row without actions is published for inspection only. The client's cursor visits focusable rows, narrowed by its own search query, so it never lands on a row the source skipped and the footer never promises a commit the cursor cannot make.
@@ -116,74 +118,20 @@ A terminal resize reaches the client as one coalesced invalidation owned by the
116118
117119 Attention is a daemon fact, not an overlay: a bell raised by a PTY, by a status-line notification, or by an agent run marks the owning tab, and the daemon publishes it in the frames its attachments receive. The status bar carries it as a pulsing glyph, the picker session header carries it inline, and `PickerLine.Attention` carries it on the tab row the client renders. Because a client-owned modal suppresses daemon paints, the attention pulse republishes the picker source for every attachment whose interaction the client presents, so a bell raised while the picker is open still reaches the displayed rows.
118120
119-## Remote hosts
120-
121-Remote discovery has two owners with separated writers. The daemon composes its own store, catalogue client, cache, and monitor for the structured rows and exact-target validation its picker serves. The launching client composes a runner-scoped `remotes.HostRegistry` over its own store, catalogue client, and a runner-local catalogue cache that seeds from the durable snapshot and never writes it, so one monitor can never truncate the other's file. The registry projects that discovery through `ports.RemoteDirectory` and resolves each endpoint once into a `ports.RemoteEndpointBinding` — the typed dialer every session on that endpoint attaches through, plus the environment to advertise — which the runner reuses for its initial remote attach and for every later handoff. Transport mode, launch allowlist, and endpoint environment stay in `internal/app` behind `ports.RemoteEndpointFactory`; the client never inspects them, and a resolution failure is never cached, so a refused endpoint fails the handoff instead of falling back to another resolver.
122-
123-Current state: the daemon-side monitor is deliberately still active. `Serve`
124-starts the runner through `daemon.WithRemoteMonitor` and joins it inside the
125-bounded remote-shutdown budget, and the daemon keeps its own remote directory
126-and host registry. That daemon-owned monitoring (and the host registry that
127-feeds it) belongs to the coordinated P7 removal set; it is not removed,
128-gated, or polled differently before that cutover, and no polling gate is
129-introduced here (GO-001, deferred to P7).
130-
131-## Prepared local inventory boundary (Plan 001 P4.3, preparation only)
132-
133-The daemon separates local registry state from foreign remote-directory rows.
134-`captureLocalSessionInventory` (returning `localSessionInventory`) copies live
135-sessions and inactive records and never reads the remote directory;
136-`captureSessionInventory` composes that local capture with the foreign rows
137-from `currentRemoteDirectory`. The local catalogue export
138-(`controlExec.RemoteCatalog`), the prepared navigation projection
139-(`localNavigationInventorySnapshot` / `localInventoryGroup`), the prepared
140-navigation resolver (`localNavigationResolve`), and the prepared picker
141-projection (`localPickerViews` / `localPickerViewProjections`) read only the
142-local capture, so they cannot observe or depend on remote monitoring. The
143-prepared control resolver (`localPickerControlAttachTarget`) is not an
144-inventory projection: it reads the live session registry directly under
145-`d.mu` and never the remote directory, the `sessionInventory` composite, or a
146-stopped record — only live registry sessions are eligible there.
147-
148-The current hybrid projections keep their foreign behavior by composing the
149-prepared local output with the foreign rows: `snapshotNavigationInventory`
150-appends `remoteInventoryGroup` per host, `pickerViewProjections` interleaves
151-`foreignPickerViews` between the local live and stopped groups, and
152-`pickerControlAttachTarget` delegates to `localPickerControlAttachTarget` (via
153-`pickerControlAttachTargetLocal`) after its remote branch. Prepared local
154-resolution is exact: a structured foreign source key, registration, remote
155-target, or remote key is rejected before any lookup; broken and purging records
156-are never resumable; a stopped target that becomes live with the same lifecycle
157-resolves; and a stale lifecycle or a same-name replacement never resolves by
158-name. The prepared local resolver also rejects a stale preferred tab, but the
159-hybrid wrapper preserves the live behavior on top of it: when the exact local
160-lifecycle is still current and only the tab vanished between snapshot and
161-resolve, it retries with an empty `PreferredTabID`, matching the client's own
162-missing-tab fallback instead of failing the attach.
163-
164-Current ownership: `internal/usecase/daemon` owns the local capture, the
165-prepared projections and resolvers, and the hybrid wrappers; `internal/app`
166-composes the daemon-side monitor; `internal/adapters/remote` plus the
167-runner-scoped `remotes.HostRegistry` own discovery. This is a preparation pass:
168-no production composition is cut over, no monitor is gated or removed, no
169-public protocol, schema, or version changes, and the signed purge machinery is
170-untouched.
171-
172-P7 removal set: `WithRemoteMonitor` and the `remoteDirectory` /
173-`remoteMonitorRun` state; `remoteDirectorySnapshot` and `currentRemoteDirectory`;
174-`watchRemoteDirectory`, `refreshRemoteDirectoryViews`, and
175-`notifyNewRemoteFailures`; the foreign picker composition (`foreignPickerViews`,
176-`remoteInventoryGroup` and the remote groups in `snapshotNavigationInventory`,
177-the remote branch of `pickerControlAttachTarget`, `resolveRemoteInventoryEntry`);
178-the hybrid wrappers themselves (`captureSessionInventory`, the
179-`sessionInventory` composite, `pickerViewProjections`, `pickerControlAttachTarget`
180-and its `pickerControlAttachTargetLocal` vanished-tab fallback) once the local
181-projections read `localSessionInventory` directly; the remote picker, palette,
182-preview, attach, and route projections that read the directory; and the
183-daemon-side `ports.RemoteDirectory` / `ports.RemotePreviewClient` composition.
184-Once those foreign inputs are deleted, the prepared local functions are the
185-whole projection. The daemon-side monitor and host registry stay active until
186-then (GO-001, deferred to P7).
121+## Broker-owned hosts and daemon composition
122+
123+`vev _broker-ready --require local-authority|local-catalogue` is an internal,
124+dial-only machine probe. It connects directly to the selected broker IPC
125+socket, registers, subscribes, and validates complete snapshots. It never
126+ensures or spawns, opens a logical stream, reconciles, or mutates. An optional
127+`--offline-root ABS` selects the offline layout. Output is exactly one bounded
128+`vev.broker-ready/v1` JSON line with decimal-string epoch/revision IDs. Exit
129+codes are 0 ready, 1 internal/write, 2 arguments, 3 timeout, 4 terminal
130+security/protocol/configuration failure, and 5 cancellation.
131+
132+The broker is the sole façade for local and remote inventory, endpoint membership, policy, observation, and logical session streams. `client.Supervisor` consumes `ports.BrokerService`; it does not import or compose a remote registry, remote adapter, endpoint factory, or direct session dialer. Host mutations and attachment requests therefore pass through the same broker authority and committed snapshot.
133+
134+A daemon serves only the sessions on its own machine. It has no remote-directory monitor and does not compose remote transports. In production it accepts the broker's authenticated multiplexed physical carriage through `internal/adapters/daemonmux`; logical observation, control, and attachment streams share that carriage while retaining independent admission and lifetime. The daemon's Unix daemonmux listener is local-only composition infrastructure between the broker and daemon, not a public client endpoint. Ordinary clients connect to `broker.sock` and never dial a daemon socket directly.
187135
188136 ## Session composition
189137
@@ -242,11 +190,20 @@ are not keys. Identity plus complete trust, launch, isolation, transport,
242190 protocol, catalogue, and environment policy select one physical connection;
243191 authenticated identity and policy are checked again after connection.
244192
245-Clients receive pool-issued epoch-scoped connection IDs and supply strictly
246-increasing stream IDs. Failed admitted opens consume their IDs. Active clients,
247-physical keys, and pending/live streams have explicit caps; retired IDs need no
248-unbounded tombstone collection. Coalesced connection attempts are independent
249-of any single waiter and are canceled when their final reservation leaves.
193+Clients receive pool-issued epoch-scoped connection IDs and allocate their own
194+logical stream IDs through `BrokerService.NextStreamID`, which is
195+thread-safe, strictly monotone, never zero, and refuses an exhausted counter
196+instead of wrapping. An allocated ID is consumed even when the open it names is
197+refused. Admission at every layer (client, listener, pool) uses one bounded
198+anti-replay window of 1024 IDs instead of a bare monotone comparison, so two
199+concurrent opens that are scheduled in either order are both admitted while a
200+replayed or evicted ID is stale. Active clients, physical keys, and
201+pending/live streams have explicit caps; retired IDs need no unbounded tombstone
202+collection. Coalesced connection attempts are independent of any single waiter
203+and are canceled when their final reservation leaves. Acquisition coalescing
204+keys on the exact policy plus the explicit `BrokerDaemonStartMode`, so an
205+existing-only request is never shared with a dial that may start the target;
206+after authentication the canonical key is identity plus exact policy alone.
250207 Physical retirement keeps its key occupied until Close finishes. Fake-clock idle
251208 eviction and shutdown close transports outside the bookkeeping lock.
252209
@@ -305,7 +262,13 @@ a stopped one.
305262 Attachment streams declare one closed admission variant
306263 (`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral
307264 creation) validated identically at the ports, broker IPC, and daemonmux
308-boundaries.
265+boundaries. Every stream also declares one closed daemon-start authorization
266+(`ports.BrokerDaemonStartMode`: `ExistingOnly` or `StartIfNeeded`), carried
267+unchanged from the open request through resolution, the wire, and the mux Open:
268+observation and daemon-stop are always `ExistingOnly` and never start a target,
269+while attach/creation and the explicit list/mutate controls may be
270+`StartIfNeeded`; the zero value is refused. The mode can only narrow the
271+resolved launch policy, never widen it.
309272
310273 ## Broker-owned local route (Plan 001 P5.3a, not activated)
311274
@@ -337,11 +300,55 @@ from the binding and never from durable membership, since the local daemon has
337300 no membership; its observed identity, incarnation, protocol version,
338301 availability, and session catalogue come only from the local probe. The local
339302 entry is never durable: the snapshot store persists and reloads remote
340-observations only. Observing the local daemon completes only the daemonmux
341-physical preamble and closes the carriage, so it never opens a logical stream
342-(never attaches) and only ever dials an existing local Unix socket, so it never
343-starts a stopped daemon. Nothing in `internal/app` or `main` activates this
344-composition for ordinary operation; P7 performs the coordinated cutover.
303+observations only. Observing the local daemon dials only an existing local Unix
304+socket, so it never starts a stopped daemon; it completes the authenticated
305+daemonmux physical preamble, opens exactly one `BrokerStreamObservation` logical
306+stream, sends one typed remote-catalog control request, validates the catalogue
307+the daemon answers, and closes the stream and carriage. It sends no `Hello`,
308+claims no geometry, requests no admission, creates no session, process, or
309+durable identity, and never attaches. The remote host probe and the local probe
310+share one semantic implementation, so the two cannot drift apart. Nothing in
311+`internal/app` or `main` activates this composition for ordinary operation; P7
312+performs the coordinated cutover.
313+
314+## Broker-owned daemon start (Plan 001 P7, UI-driver slice 1)
315+
316+The one place a resolved start authorization reaches transport mechanics is
317+`internal/app/broker_daemon_start.go`. `daemonmux.RawCarrierDialer` receives the
318+whole `ports.BrokerDialTarget` — address, fence, policy, `StartMode`, and
319+expected identity — instead of a bare opaque address, so an authorization can
320+never be flattened into an address or smuggled through a context value, and the
321+carriage owner sees exactly the authority the connector validated and is about
322+to verify in the physical preamble. `brokerMuxConnector` looks the route up by
323+the target's opaque address, re-checks that the provisioned entry still matches
324+the resolved fence and policy, and passes the whole target on.
325+
326+`dialBrokerLocalDaemon` dials one local daemon for one resolved target.
327+`BrokerDaemonExistingOnly` is exactly one private daemonmux carriage dial: no
328+lifecycle probe, no spawn lock, no process, and no fallback of any kind.
329+`BrokerDaemonStartIfNeeded` performs the same dial first and returns the daemon
330+it finds — an already running daemon is never restarted. Only a dial that proves
331+absence (an unclassified failure, a cancellation, a foreign path, a rejected
332+peer, and a permission refusal all report false) can reach the start, and then
333+only when the resolved policy names the broker launch authority and the carriage
334+is exactly the private endpoint the launched daemon will publish. A registered
335+endpoint is never started by the local mechanics. The start itself is the shared
336+election in `spawn.go` — lifecycle availability, one elected spawner under the
337+spawn lock, then a bounded redial of the same carriage — so observation and
338+daemon-stop stay `ExistingOnly` and a policy that forbids launching still
339+refuses visibly. `spawn.go` is generic over the dialed value, so the session
340+transport and the daemonmux carriage obey exactly one election discipline, and
341+no path here ever dials a session or a session control stream to prove
342+readiness.
343+
344+The remote-side mux helpers apply the same rule from their own broker-owned
345+configuration. `dialBrokerRoute` propagates the target's `StartMode` before any
346+connect-or-spawn, `sshMuxCommandSpec` appends the closed
347+`--daemon-start existing-only|if-needed` word to the provisioned remote argv,
348+and `parseBrokerMuxArgs` defaults an absent flag to the safe `existing-only`
349+while refusing an unknown value. A helper starts only the daemon behind its own
350+provisioned mux carriage, under its own policy, and never starts a broker, an
351+observer, or a recursive helper.
345352
346353 ## Broker local IPC (Plan 001 P3.3, not activated)
347354
@@ -351,9 +358,7 @@ per-user Unix endpoint. The carriage is the P3.2 private AF_UNIX carriage in
351358 parent directory, a bound socket tightened to 0600, race-safe recovery of a
352359 stale socket whose owner died, refusal (never removal) of a path that is not a
353360 socket, and same-user kernel peer credentials (`SO_PEERCRED` on Linux) on both
354-accept and dial, failing closed on a platform or build without that check. The
355-endpoint name (`broker.sock`) is deliberately distinct from the daemon's
356-`daemon.sock`.
361+accept and dial, failing closed on a platform or build without that check. The endpoint name (`broker.sock`) is the client-facing local endpoint. The daemon's Unix daemonmux socket is local-only composition infrastructure owned by the broker/daemon path; it is not published as a client API and ordinary clients never dial it.
357362
358363 The listener owns its accept loop: one client slot is acquired before each
359364 accept, each accepted carriage completes the broker preamble and broker
@@ -528,13 +533,21 @@ bounded route address: a digest of the route's own contents, so the pooling
528533 layer never carries a target, a path, or an argv word.
529534
530535 `_broker-serve` composes a transport-selecting `daemonmux.EndpointConnector`. Its
531-dial function receives only the opaque address, looks the route up in the
532-immutable configuration it was built with, and refuses an address the
533-configuration did not produce. A `unix` route dials the provisioned private Unix
534-mux carriage directly; an `ssh-stdio` route starts one explicit ssh child (built
536+dial function receives the whole resolved `ports.BrokerDialTarget`, looks the
537+route up by its opaque address in the immutable configuration it was built
538+with, refuses an address the configuration did not produce, and re-checks that
539+the provisioned entry still matches the resolved fence and policy before
540+dialing. The target travels whole so the closed daemon-start authorization
541+reaches the transport owner instead of being flattened into an address. A
542+`unix` route dials the provisioned private Unix
543+mux carriage directly when it is a provisioned endpoint carriage, and goes
544+through the broker-owned daemon start (below) when it is the broker's own local
545+binding route; an `ssh-stdio` route starts one explicit ssh child (built
535546 with `sshstdio.BuildCommandForMux` and carrying only trust inputs that narrow
536547 verification, never disabling host-key checking, forcing an auth bypass, or
537-requesting a PTY) through `sshstdio.DialMuxContext`; an `ssh-quic` route runs one
548+requesting a PTY) through `sshstdio.DialMuxContext`, and appends the closed
549+`--daemon-start existing-only|if-needed` word to the provisioned remote argv; an
550+`ssh-quic` route runs one
538551 SSH-authenticated QUIC bootstrap, reads exactly one readiness line, and pins the
539552 freshly minted ephemeral certificate through `quic.DialMuxContext`. One
540553 caller-supplied setup context bounds bootstrap, pinned dial, and the daemonmux
@@ -551,15 +564,22 @@ and sanitized, so a token, a nonce, or raw remote text never reaches an address,
551564 a log, or an error.
552565
553566 The three hidden helpers are excluded from public help. `_broker-mux-stdio
554---offline-root ABS` validates the sandbox and bridges its own stdio to the single
555-provisioned private Unix daemonmux carriage. `_broker-mux-quic-bootstrap
567+--offline-root ABS [--daemon-start existing-only|if-needed]` validates the
568+sandbox and bridges its own stdio to the single
569+provisioned private Unix daemonmux carriage, applying exactly the start
570+authorization the broker propagated: an absent flag defaults to the safe
571+`existing-only`, an unknown value is refused, and a `start-if-needed` request is
572+gated by the helper's own provisioned identity and policy, which can only narrow
573+it. `_broker-mux-quic-bootstrap
556574 --offline-root ABS` validates the sandbox, starts one detached
557575 `_broker-mux-quic-proxy` in a new session, forwards its single readiness line,
558576 and exits. `_broker-mux-quic-proxy --offline-root ABS` mints one ephemeral QUIC
559577 server, admits exactly one authenticated carriage, and bridges it to the same
560-private Unix daemonmux carriage. A helper only ever bridges to the configured
561-private endpoint: it never starts a broker, an observer, or an ordinary daemon,
562-never dials the production daemon socket, and never fabricates a daemon
578+private Unix daemonmux carriage under the same propagated authorization. A
579+helper only ever bridges to the configured
580+private endpoint: it never starts a broker, an observer, or an ordinary daemon
581+outside that one daemon-start authorization, never dials the production daemon
582+socket, and never fabricates a daemon
563583 incarnation, which the daemon on the far side of the carriage alone owns.
564584 Ordinary production composition still waits for P7; the helpers change no
565585 ordinary command, path, or factory.
M docs/configuration.md +0 -39
@@ -7,13 +7,6 @@ vev reads `~/.config/vev/config` (`$XDG_CONFIG_HOME` respected). No file means d
77 web.listen = 127.0.0.1:8778
88 web.origin = http://127.0.0.1:8778
99
10-# Client startup setting: retain suspended remote attachments. Reuse is on by
11-# default; off disables it. Capacity bounds dormant destinations (LRU). An
12-# optional positive idle timeout expires dormant entries; off has no age bound.
13-remote.attachment-cache = on
14-remote.attachment-cache-capacity = 8
15-remote.attachment-cache-idle-timeout = off
16-
1710 # Theme: auto follows the client; dark/light use neutral built-in defaults.
1811 theme = auto
1912 # In auto mode with palette inheritance enabled, infer a terminal accent.
@@ -95,38 +88,6 @@ code.detach = DET
9588
9689 Invalid values log a warning and resolve that setting to its default on both initial load and reload.
9790
98-## Remote attachment cache
99-
100-These keys are read when a terminal client starts, not hot reloaded.
101-`remote.attachment-cache` is `on` (default) or `off`. With reuse enabled the
102-runner retains at most `remote.attachment-cache-capacity` dormant destinations
103-(default `8`), evicting the least-recently used one when a new suspension would
104-exceed capacity. `remote.attachment-cache-idle-timeout` is `off` (default, no
105-age bound) or a positive Go duration such as `30s` or `5m`; a positive value
106-expires a dormant entry that long after it was suspended.
107-
108-Navigating away suspends the remote attachment. Returning before eviction or
109-expiry reuses its authenticated connection without another bootstrap or dial.
110-Dormant attachments have no terminal input, output, or geometry authority.
111-Expiry, remote closure, or rejected activation evicts the connection; selecting
112-that endpoint then uses normal reconnect/attach behavior. Independently of the
113-client policy, the daemon force-retires an attachment suspended for 24 hours as
114-a safety bound. Endpoint aliases remain distinct, even if they resolve to the
115-same server. Exiting the client closes its retained connections. Heartbeat or
116-liveness traffic never refreshes
117-activity: a successful use followed by suspension marks the entry most
118-recently used, and only user navigation changes recency.
119-
120-Retention is bounded by the LRU capacity and, when configured, the idle
121-timeout, so visiting many distinct endpoints cannot retain more than the
122-capacity. Each dormant entry retains a transport, a reader, and daemon
123-attachment state; SSH stdio can also retain an SSH process. The client inbox is
124-bounded and dormant output is discarded. Warm reactivation has a short
125-client-local budget: a stalled retained transport is logically relinquished at
126-that deadline and the client cold-attaches immediately while the old transport
127-retires in the background. Use `remote.attachment-cache = off` when no
128-retention is wanted.
129-
13091 ## Scrollback
13192
13293 `vev` chooses the retention policy; `vev-vt` enforces it. The PTY transports
M docs/ui-driver.md +63 -44
@@ -4,18 +4,66 @@
44
55 ## Headless attachment
66
7-Start a new numbered session with the default 80x24 viewport:
7+Start a new numbered ephemeral session with the default 80x24 viewport:
88
99 ```sh
1010 vev --ui-driver
1111 ```
1212
13-The first line on stdout is the attachment discovery response. It is emitted only after the client has applied and published its initial full view:
13+`--ui-driver` uses the per-user broker of the process' own XDG context. It never
14+provisions, reconfigures, or destroys a daemon, a session-scoped root, or an
15+endpoint: creation is expressed as one initial navigation through the broker, so
16+two drivers can share one per-user broker without either owning it.
17+
18+The closed option set is:
19+
20+- `--session NAME` creates that named local session instead of an ephemeral one.
21+- `--remote ENDPOINT` targets that configured remote endpoint: with `--session`
22+it resolves that exact remote session, without it it creates a remote ephemeral
23+session. Every remote identity comes from the broker's committed publication,
24+never from a hostname or an environment value.
25+- `--cols N --rows N` set the viewport, at most 512x256.
26+- `--picker` starts with no initial navigation at all and is exclusive of
27+`--session` and `--remote`.
28+- `--socket ABS` is the JSONL bridge to an already running opted-in client, and
29+is exclusive of every headless option.
30+
31+`--launch-config` no longer exists. It is an unknown option and is refused with
32+the usage exit code before any terminal, broker, or filesystem interaction; a
33+driver that needs an isolated endpoint provisions it through the broker's own
34+configuration instead.
35+
36+The first line on stdout is the discovery response. It is emitted as soon as the
37+client has published its initial state, which does not require a broker or a
38+daemon:
1439
1540 ```json
16-{"version":1,"id":0,"result":{"attachment":"<opaque-handle>","generation":1,"control":true,"status":"attached"}}
41+{"version":1,"id":0,"result":{"attachment":"<opaque-handle>","generation":0,"control":true,"status":"picker"}}
1742 ```
1843
44+`status` is a closed union of exactly three presentations:
45+
46+| `status` | meaning | `generation` | session metadata in `capture`/`wait` | `keys`/`text` |
47+|---|---|---:|---|---|
48+| `picker` | the local picker; ready without a broker and without a daemon | `0` | all zero | refused (`unavailable`) |
49+| `connecting` | an attachment attempt that has not committed its first frame | `0` | all zero | refused (`unavailable`) |
50+| `attached` | a committed attachment | nonzero real action generation | the validated session identity and committed boundary | admitted under the current binding |
51+
52+The former `detached`, `reconnecting`, and `transitioning` states no longer
53+exist and are not aliases of these three: a reconnect is `connecting`, a detach
54+is `picker`, and terminating closes the service instead of becoming a fourth
55+persistent presentation. A driver that never publishes anything is a real local
56+error; a destination that is refused, times out, or is lost leaves a published
57+`picker` and the same JSONL service running.
58+
59+The `attachment` handle is the UI service of this run, never a session identity,
60+and it is stable for the whole run. `generation` is zero until a committed
61+attachment publishes one; a client must wait for `status` to become `attached`
62+and use the generation that publication reports, and never assume
63+`generation == 1`. A broker that is absent or incompatible is not fatal: the
64+driver stays in the picker with a bounded notice and keeps answering `capture`
65+and `wait`.
66+
1967 The stream then accepts one JSON object per line. Every request has `version`, a nonzero `id`, `op`, and the discovered `attachment`:
2068
2169 ```json
@@ -52,7 +100,7 @@ A request timeout only ends that request. If input was already accepted, the res
52100 {"version":1,"id":6,"op":"wait","attachment":"<opaque-handle>","after_action":4,"timeout_ms":5000,"expect":{"text_contains":"driver ok","status":"attached"}}
53101 ```
54102
55-Supported predicates are literal `text_contains`, an exact `session` (`lifecycle_id` and `session_name`), an exact `focus` (`tab_id` and `pane_id`), and `status` (`attached`, `transitioning`, `reconnecting`, or `detached`). Without `after_action`, a wait may match the current published snapshot. With it, only the action's confirmed publication boundary and later snapshots are eligible. This is a postcondition check, not a claim that the action alone caused the text.
103+Supported predicates are literal `text_contains`, an exact `session` (`lifecycle_id` and `session_name`), an exact `focus` (`tab_id` and `pane_id`), and `status` (`picker`, `connecting`, or `attached`). Without `after_action`, a wait may match the current published snapshot. With it, only the action's confirmed publication boundary and later snapshots are eligible. This is a postcondition check, not a claim that the action alone caused the text.
56104
57105 ## Limits and errors
58106
@@ -66,49 +114,20 @@ Supported predicates are literal `text_contains`, an exact `session` (`lifecycle
66114
67115 Errors are structured and sanitized. Stable codes include `invalid_request`, `unsupported_version`, `permission_denied`, `stale_attachment`, `unavailable`, `timeout`, `outcome_unknown`, `capture_too_large`, `action_expired`, `input_busy`, `busy`, `navigation_failed`, and `endpoint_not_configured`. Error messages contain only the code; screen contents, credentials, endpoint internals, and environment values are not logged or returned.
68116
69-EOF detaches this driver attachment and closes its access stream. It does not kill shells or sessions on an existing endpoint. For an explicit launch configuration, the app performs a separate owned-endpoint teardown after driver access closes; it stops and removes only roots this invocation created.
70-
71-## Explicit launch configuration
72-
73-Without `--launch-config`, normal local daemon startup and inherited client environment policy are unchanged. The option is the only way to request an explicit isolated endpoint:
117+EOF detaches this driver attachment and closes its access stream. It does not kill shells or sessions, does not stop the shared broker, and does not remove any root: the broker, its daemon, and every session stay alive for the other clients, and the pool's own idle shutdown remains the only thing that ends the daemon.
74118
75-```sh
76-vev --ui-driver --session test --cols 100 --rows 30 \
77- --launch-config /absolute/path/launch.json
78-```
79-
80-The configuration file must be owned by the current user, mode `0600`, valid UTF-8 JSON, and at most 64 KiB:
81-
82-```json
83-{
84- "version": 1,
85- "local": {
86- "binary": "/absolute/path/vev",
87- "root": "/absolute/path/new-local-root",
88- "env": {
89- "HOME": "/home/test",
90- "PATH": "/usr/bin:/bin",
91- "SHELL": "/bin/sh"
92- }
93- },
94- "remotes": [
95- {
96- "endpoint": "user@example.com",
97- "binary": "/absolute/path/vev",
98- "root": "/absolute/path/new-remote-root",
99- "env": {
100- "HOME": "/home/test",
101- "PATH": "/usr/bin:/bin",
102- "SHELL": "/bin/sh"
103- }
104- }
105- ]
106-}
107-```
119+## Isolation
108120
109-`local` may be omitted only for a direct `--remote` start. `remotes` defaults to an empty list. Binary and root paths must be absolute. Environment maps are complete child environments, not merges with the invoking process. `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `XDG_RUNTIME_DIR`, `VEV_ENV`, and `VEV_ENV_ROOT` are reserved and supplied by vev; they cannot appear in the file. Values are passed as argument data through the existing process/SSH builders, never by shell-concatenating untrusted strings.
121+The driver owns no endpoint root, no daemon, and no process. An isolated endpoint
122+is provisioned through the broker's own configuration before the driver starts,
123+exactly like any other broker route: the broker is the single authority over
124+binary, root, environment, trust, and launch policy, and those values are never a
125+payload of a JSONL request, a selected hostname, or a client environment variable.
126+There is no per-invocation root creation, no owner token, and no remote cleanup
127+step at driver exit.
110128
111-A configured root must not exist before the invocation creates it. Local roots and remotely created roots use private derived config, state, runtime, and temporary directories; a remote root is reused only by later connections carrying the same invocation-owned token. A launch-config invocation refuses to fall back to an unlisted remote endpoint, including during catalogue and preview discovery. Directory isolation is not a process, filesystem, or network sandbox; environment files and binaries remain sensitive.
129+Directory isolation is not a process, filesystem, or network sandbox; environment
130+files and binaries remain sensitive.
112131
113132 ## Existing interactive clients
114133
M internal/adapters/brokerconfig/config.go +34 -3
@@ -294,6 +294,31 @@ func Load(layout Layout) (*Config, error) {
294294 // LoadPath parses the broker configuration at an explicit application-owned
295295 // path while retaining the route and reserved-path validation from layout.
296296 func LoadPath(layout Layout, path string) (*Config, error) {
297+ return loadPath(layout, path, nil)
298+}
299+
300+// LoadProduction reads broker.json while deriving local daemon authority from
301+// daemon-owned state. The production file must not carry a local identity.
302+func LoadProduction(layout Layout, path string, identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, socketPath string) (*Config, error) {
303+ // An absent identity is valid before the local daemon's first start. A
304+ // present identity remains strict and becomes the expected handshake fence.
305+ if identity != "" {
306+ if err := identity.Validate(); err != nil {
307+ return nil, err
308+ }
309+ }
310+ if err := policy.Validate(); err != nil {
311+ return nil, err
312+ }
313+ route, err := newUnixRoute(socketPath)
314+ if err != nil {
315+ return nil, err
316+ }
317+ local := &LocalBinding{Identity: identity, DisplayOrigin: LocalDisplayOrigin, Policy: policy, Route: route}
318+ return loadPath(layout, path, local)
319+}
320+
321+func loadPath(layout Layout, path string, authoritativeLocal *LocalBinding) (*Config, error) {
297322 raw, err := readConfig(path)
298323 if err != nil {
299324 return nil, err
@@ -351,9 +376,15 @@ func LoadPath(layout Layout, path string) (*Config, error) {
351376 }
352377 config.order = append(config.order, registration.Registration.Endpoint)
353378 }
354- local, err := parseLocal(document.Local)
355- if err != nil {
356- return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err)
379+ if authoritativeLocal != nil && document.Local != nil {
380+ return nil, fmt.Errorf("brokerconfig: %s: production local authority must not be configured", ConfigFileName)
381+ }
382+ local := authoritativeLocal
383+ if local == nil {
384+ local, err = parseLocal(document.Local)
385+ if err != nil {
386+ return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err)
387+ }
357388 }
358389 if local != nil {
359390 if err := rejectRouteOverlap(local.Route.Path(), layout.reserved); err != nil {
M internal/adapters/brokerconfig/config_test.go +13 -8
@@ -120,15 +120,17 @@ func TestLoadValidConfig(t *testing.T) {
120120 require.NoError(t, err)
121121 require.Equal(t, []string{testEndpoint}, config.Endpoints())
122122
123- resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{
123+ resolved, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{
124124 Purpose: ports.BrokerStreamControl,
125125 Endpoint: testEndpoint,
126126 Registration: testRegistration(),
127127 Policy: testPolicy(),
128+ StartMode: ports.BrokerDaemonExistingOnly,
128129 })
129130 require.NoError(t, err)
130- require.Equal(t, ports.BrokerDaemonIdentity("offline-daemon"), resolved.Identity)
131+ require.Equal(t, ports.BrokerDaemonIdentity("offline-daemon"), resolved.ExpectedIdentity.Identity)
131132 require.Equal(t, testPolicy(), resolved.Policy)
133+ require.Equal(t, ports.BrokerDaemonExistingOnly, resolved.StartMode, "resolution propagates the requested start mode")
132134
133135 local, err := config.LocalMuxRoute()
134136 require.NoError(t, err)
@@ -484,14 +486,16 @@ func TestLoadAliasesShareOneRoute(t *testing.T) {
484486 for _, endpoint := range []string{first, second} {
485487 registration := testRegistration()
486488 registration.Endpoint = endpoint
487- resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{
489+ resolved, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{
488490 Purpose: ports.BrokerStreamControl,
489491 Endpoint: endpoint,
490492 Registration: registration,
491493 Policy: testPolicy(),
494+ StartMode: ports.BrokerDaemonStartIfNeeded,
492495 })
493496 require.NoError(t, err)
494497 require.Equal(t, local.Address(), resolved.Address)
498+ require.Equal(t, ports.BrokerDaemonStartIfNeeded, resolved.StartMode)
495499 }
496500 }
497501
@@ -535,12 +539,13 @@ func TestResolverRefusals(t *testing.T) {
535539 Endpoint: testEndpoint,
536540 Registration: testRegistration(),
537541 Policy: testPolicy(),
542+ StartMode: ports.BrokerDaemonExistingOnly,
538543 }
539544
540545 t.Run("unknown endpoint", func(t *testing.T) {
541546 request := base
542547 request.Endpoint = "other@daemon"
543- _, err := resolver.Resolve(context.Background(), request)
548+ _, err := resolver.ResolveDialTarget(context.Background(), request)
544549 var typed ports.BrokerError
545550 require.ErrorAs(t, err, &typed)
546551 require.Equal(t, ports.BrokerErrorUnavailable, typed.Code)
@@ -549,7 +554,7 @@ func TestResolverRefusals(t *testing.T) {
549554 t.Run("local request", func(t *testing.T) {
550555 request := base
551556 request.Local = true
552- _, err := resolver.Resolve(context.Background(), request)
557+ _, err := resolver.ResolveDialTarget(context.Background(), request)
553558 var typed ports.BrokerError
554559 require.ErrorAs(t, err, &typed)
555560 require.Equal(t, ports.BrokerErrorUnavailable, typed.Code)
@@ -558,7 +563,7 @@ func TestResolverRefusals(t *testing.T) {
558563 t.Run("stale registration", func(t *testing.T) {
559564 request := base
560565 request.Registration.Generation = 2
561- _, err := resolver.Resolve(context.Background(), request)
566+ _, err := resolver.ResolveDialTarget(context.Background(), request)
562567 var typed ports.BrokerError
563568 require.ErrorAs(t, err, &typed)
564569 require.Equal(t, ports.BrokerErrorUnavailable, typed.Code)
@@ -567,7 +572,7 @@ func TestResolverRefusals(t *testing.T) {
567572 t.Run("conflicting policy", func(t *testing.T) {
568573 request := base
569574 request.Policy.Transport = "other-transport"
570- _, err := resolver.Resolve(context.Background(), request)
575+ _, err := resolver.ResolveDialTarget(context.Background(), request)
571576 var typed ports.BrokerError
572577 require.ErrorAs(t, err, &typed)
573578 require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code)
@@ -575,7 +580,7 @@ func TestResolverRefusals(t *testing.T) {
575580 t.Run("cancelled context", func(t *testing.T) {
576581 ctx, cancel := context.WithCancel(context.Background())
577582 cancel()
578- _, err := resolver.Resolve(ctx, base)
583+ _, err := resolver.ResolveDialTarget(ctx, base)
579584 require.ErrorIs(t, err, context.Canceled)
580585 })
581586 }
M internal/adapters/brokerconfig/local_test.go +8 -7
@@ -57,9 +57,10 @@ func loadDocument(t *testing.T, document any, reserved []string) (*Config, error
5757 // localRequest builds one local open-stream request under the fixture policy.
5858 func localRequest() ports.BrokerOpenStreamRequest {
5959 return ports.BrokerOpenStreamRequest{
60- Purpose: ports.BrokerStreamControl,
61- Local: true,
62- Policy: testPolicy(),
60+ Purpose: ports.BrokerStreamControl,
61+ Local: true,
62+ Policy: testPolicy(),
63+ StartMode: ports.BrokerDaemonStartIfNeeded,
6364 }
6465 }
6566
@@ -78,9 +79,9 @@ func TestLoadLocalBinding(t *testing.T) {
7879 require.True(t, binding.Route.IsLocal())
7980 require.Equal(t, localTestSocket, binding.Route.Path())
8081
81- resolved, err := config.Resolver().Resolve(context.Background(), localRequest())
82+ resolved, err := config.Resolver().ResolveDialTarget(context.Background(), localRequest())
8283 require.NoError(t, err)
83- require.Equal(t, binding.Identity, resolved.Identity)
84+ require.Equal(t, binding.Identity, resolved.ExpectedIdentity.Identity)
8485 require.Equal(t, binding.Policy, resolved.Policy)
8586 require.Equal(t, binding.Route.Address(), resolved.Address)
8687
@@ -276,10 +277,10 @@ func TestResolveLocalSuccessAndRefusals(t *testing.T) {
276277 cancel()
277278 ctx = cancelled
278279 }
279- resolved, err := tt.resolver.Resolve(ctx, tt.request)
280+ resolved, err := tt.resolver.ResolveDialTarget(ctx, tt.request)
280281 if tt.wantErr == nil {
281282 require.NoError(t, err)
282- require.Equal(t, binding.Identity, resolved.Identity)
283+ require.Equal(t, binding.Identity, resolved.ExpectedIdentity.Identity)
283284 require.Equal(t, binding.Policy, resolved.Policy)
284285 require.Equal(t, binding.Route.Address(), resolved.Address)
285286 return
M internal/adapters/brokerconfig/resolver.go +22 -14
@@ -7,7 +7,7 @@ import (
77 "github.com/bnema/vev/internal/ports"
88 )
99
10-// Resolver is the immutable ports.BrokerEndpointResolver over one parsed
10+// Resolver is the immutable ports.BrokerRouteAuthority over one parsed
1111 // offline configuration. It holds no mutable state and performs no I/O: every
1212 // Resolve call fences the request against the provisioned registration and
1313 // returns the configured identity, policy, and opaque route address. A request
@@ -24,7 +24,7 @@ type Resolver struct {
2424 local *LocalBinding
2525 }
2626
27-var _ ports.BrokerEndpointResolver = (*Resolver)(nil)
27+var _ ports.BrokerRouteAuthority = (*Resolver)(nil)
2828
2929 // Resolve fences one open-stream request against the immutable configuration.
3030 //
@@ -40,26 +40,30 @@ var _ ports.BrokerEndpointResolver = (*Resolver)(nil)
4040 // Unknown, stale, conflicting, or unprovisioned requests are refused with a
4141 // typed broker error and no dial is attempted; a cancelled context is reported
4242 // as such so the pool classifies it as cancellation rather than unavailability.
43-func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
43+func (r *Resolver) ResolveDialTarget(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) {
4444 if err := ctx.Err(); err != nil {
45- return ports.BrokerResolvedEndpoint{}, err
45+ return ports.BrokerDialTarget{}, err
4646 }
4747 if request.Local {
4848 return r.resolveLocal(request)
4949 }
5050 provisioned, ok := r.byEndpoint[request.Endpoint]
5151 if !ok {
52- return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errUnknownEndpoint}
52+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "unknown endpoint", Cause: errUnknownEndpoint}
5353 }
5454 if !request.Registration.Equal(provisioned.Registration) {
55- return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errStaleRegistration}
55+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "stale registration", Cause: errStaleRegistration}
5656 }
5757 if !provisioned.Policy.Compatible(request.Policy) {
58- return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("brokerconfig: request policy conflicts with the provisioned policy")}
58+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("brokerconfig: request policy conflicts with the provisioned policy")}
59+ }
60+ resolved := ports.BrokerDialTarget{
61+ Fence: ports.BrokerEndpointFence{Registration: provisioned.Registration}, Policy: provisioned.Policy, Address: provisioned.Route.Address(),
62+ StartMode: request.StartMode,
63+ ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: provisioned.Identity, Bound: provisioned.Identity != ""},
5964 }
60- resolved := ports.BrokerResolvedEndpoint{Identity: provisioned.Identity, Policy: provisioned.Policy, Address: provisioned.Route.Address()}
6165 if err := resolved.Validate(); err != nil {
62- return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
66+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
6367 }
6468 return resolved, nil
6569 }
@@ -70,16 +74,20 @@ func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRe
7074 // provisioned local policy is refused as a conflicting policy. The resolved
7175 // identity, policy, and address are the binding's own, so the request supplies
7276 // none of them.
73-func (r *Resolver) resolveLocal(request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
77+func (r *Resolver) resolveLocal(request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) {
7478 if r.local == nil {
75- return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errNoLocalRoute}
79+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "local route is not provisioned", Cause: errNoLocalRoute}
7680 }
7781 if !r.local.Policy.Compatible(request.Policy) {
78- return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errLocalPolicyConflict}
82+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errLocalPolicyConflict}
83+ }
84+ resolved := ports.BrokerDialTarget{
85+ Fence: ports.BrokerEndpointFence{Local: true}, Policy: r.local.Policy, Address: r.local.Route.Address(),
86+ StartMode: request.StartMode,
87+ ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: r.local.Identity, Bound: r.local.Identity != ""},
7988 }
80- resolved := ports.BrokerResolvedEndpoint{Identity: r.local.Identity, Policy: r.local.Policy, Address: r.local.Route.Address()}
8189 if err := resolved.Validate(); err != nil {
82- return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
90+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
8391 }
8492 return resolved, nil
8593 }
M internal/adapters/brokerconfig/route.go +13 -0
@@ -15,6 +15,7 @@ import (
1515 "unicode/utf8"
1616
1717 "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/ports"
1819 )
1920
2021 // Bounded route variants (Plan 001 P3.4 slice E).
@@ -317,6 +318,18 @@ func parseTrust(document *trustDocument) (TrustInputs, error) {
317318 return trust, nil
318319 }
319320
321+// RouteFromSpec materializes canonical durable routing authority. Runtime SSH
322+// trust is OpenSSH-owned; no startup configuration lookup participates.
323+func RouteFromSpec(spec ports.BrokerRouteSpec) (Route, error) {
324+ if err := spec.Validate(); err != nil {
325+ return Route{}, err
326+ }
327+ if spec.Kind == ports.BrokerRouteUnix {
328+ return newUnixRoute(spec.Path)
329+ }
330+ return newSSHRoute(RouteKind(spec.Kind), spec.Target, spec.Argv, TrustInputs{})
331+}
332+
320333 func newUnixRoute(path string) (Route, error) {
321334 route := Route{kind: RouteUnix, path: path, local: true}
322335 if err := route.Validate(); err != nil {
M internal/adapters/brokeripc/client.go +54 -29
@@ -216,14 +216,16 @@ type client struct {
216216 err error
217217 sub *clientSubscription
218218 generation brokerwire.SubscriptionGeneration
219- highest ports.BrokerStreamID
220219 nextStream ports.BrokerStreamID
221220 snapshot ports.BrokerSnapshot
222221 pending map[ports.BrokerOperationID]chan operationResult
223222 kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind
224- streams map[ports.BrokerStreamID]*clientStream
225- closeOnce sync.Once
226- done chan struct{}
223+ // The connection's own stream tracker is the anti-replay authority for
224+ // this connection: it admits every identity at most once inside the bounded
225+ // window, whether the open is afterwards accepted or refused.
226+ streams map[ports.BrokerStreamID]*clientStream
227+ closeOnce sync.Once
228+ done chan struct{}
227229 }
228230
229231 var _ ports.BrokerService = (*client)(nil)
@@ -349,11 +351,15 @@ func (c *client) dispatch(message brokerwire.ServerMessage) error {
349351 }
350352 cause := failureFromDetail(m.Error)
351353 if cause == nil {
352- cause = ports.BrokerError{Code: ports.BrokerErrorUnavailable}
354+ cause = ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "empty broker error frame"}
353355 }
354356 return cause
355357 case brokerwire.Shutdown:
356- return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: m.Text}
358+ text := m.Text
359+ if text == "" {
360+ text = "broker shutdown"
361+ }
362+ return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: text}
357363 default:
358364 return errors.Join(ErrProtocol, errors.New("brokeripc: unexpected server message"))
359365 }
@@ -403,6 +409,27 @@ func (c *client) completeOperation(operation ports.BrokerOperationID, result ope
403409 // ConnectionID returns the identity assigned to this connection at accept.
404410 func (c *client) ConnectionID() ports.BrokerConnectionID { return c.scope.Connection }
405411
412+// NextStreamID allocates this connection's next logical stream identity. It is
413+// strictly monotone, never zero, and consumes no wire frame: it is the local
414+// allocation of the calling service, which then carries the exact
415+// epoch/connection/stream triplet on OpenStream. It performs no I/O and refuses
416+// a closed connection or an exhausted counter rather than wrapping.
417+func (c *client) NextStreamID() (ports.BrokerStreamID, error) {
418+ if c == nil {
419+ return 0, ErrConnectionClosed
420+ }
421+ c.mu.Lock()
422+ defer c.mu.Unlock()
423+ if c.closed {
424+ return 0, c.terminalErrorLocked()
425+ }
426+ if c.nextStream == ^ports.BrokerStreamID(0) {
427+ return 0, ports.BrokerAdmissionLimit
428+ }
429+ c.nextStream++
430+ return c.nextStream, nil
431+}
432+
406433 // Done closes exactly once when this connection is terminal: the carriage
407434 // failed, the broker retired it, or this side closed it. Err is stable
408435 // afterwards.
@@ -476,9 +503,13 @@ func (c *client) Subscribe() (ports.BrokerSubscription, error) {
476503 }
477504
478505 // OpenStream opens one independently cancellable logical stream. An absent
479-// epoch, connection, or stream identity is filled from this connection; a
480-// caller-supplied identity that belongs elsewhere, or a stream identity that is
481-// not strictly increasing, is refused as stale.
506+// epoch or connection identity is filled from this connection and a
507+// caller-supplied identity that belongs elsewhere is refused as stale. The
508+// stream identity must be the exact one the calling service allocated with
509+// NextStreamID: it is admitted on this connection's bounded anti-replay window
510+// before any frame travels, so a duplicate and an identity evicted past the
511+// window are both refused, while a concurrent lower identity that was allocated
512+// first is still admitted.
482513 func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
483514 scoped, err := c.scopeRequest(request)
484515 if err != nil {
@@ -491,7 +522,10 @@ func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamR
491522 return nil, err
492523 }
493524 // Admit the identity on the connection's own tracker before any frame
494- // travels, so a racing StreamOpened can never precede local admission.
525+ // travels, so a racing StreamOpened can never precede local admission. The
526+ // tracker applies the bounded anti-replay window: a duplicate and an
527+ // identity evicted past the window are refused, while a concurrent lower
528+ // identity that was allocated first is still admitted.
495529 if err := c.conn.OpenStream(scoped.Stream); err != nil {
496530 c.mu.Unlock()
497531 return nil, admissionError(err)
@@ -514,6 +548,7 @@ func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamR
514548 Local: scoped.Local,
515549 Endpoint: scoped.Endpoint, Registration: scoped.Registration,
516550 Target: scoped.Target, Env: scoped.Env, Policy: scoped.Policy,
551+ StartMode: scoped.StartMode,
517552 }); err != nil {
518553 c.retireStream(scoped.Stream, err)
519554 return nil, err
@@ -615,8 +650,12 @@ func (c *client) Close() error {
615650 return nil
616651 }
617652
618-// scopeRequest fills an absent identity from this connection and refuses one
619-// that belongs elsewhere, then validates the resulting request.
653+// scopeRequest fills an absent epoch or connection identity from this
654+// connection and refuses one that belongs elsewhere, then validates the
655+// resulting request. The stream identity must be present: after cutover the
656+// calling service allocates it with NextStreamID and carries the exact
657+// epoch/connection/stream triplet, so this adapter never invents an identity on
658+// an incomplete request.
620659 func (c *client) scopeRequest(request ports.BrokerOpenStreamRequest) (ports.BrokerOpenStreamRequest, error) {
621660 scoped := request
622661 if scoped.Epoch == 0 {
@@ -629,23 +668,9 @@ func (c *client) scopeRequest(request ports.BrokerOpenStreamRequest) (ports.Brok
629668 } else if scoped.Connection != c.scope.Connection {
630669 return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale)
631670 }
632- c.mu.Lock()
633- switch {
634- case scoped.Stream == 0:
635- if c.nextStream == ^ports.BrokerStreamID(0) {
636- c.mu.Unlock()
637- return ports.BrokerOpenStreamRequest{}, ports.BrokerAdmissionLimit
638- }
639- c.nextStream++
640- scoped.Stream = c.nextStream
641- c.highest = scoped.Stream
642- case scoped.Stream <= c.highest:
643- c.mu.Unlock()
644- return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale)
645- default:
646- c.highest = scoped.Stream
671+ if scoped.Stream == 0 {
672+ return ports.BrokerOpenStreamRequest{}, errors.Join(ports.BrokerAdmissionInvalid, errors.New("brokeripc: open stream request has no stream identity"))
647673 }
648- c.mu.Unlock()
649674 if err := scoped.Validate(); err != nil {
650675 return ports.BrokerOpenStreamRequest{}, errors.Join(ports.BrokerAdmissionInvalid, err)
651676 }
@@ -742,7 +767,7 @@ func (c *client) sendAsync(message brokerwire.ClientMessage) error {
742767 if async, ok := c.transport.(wire.AsyncTransport); ok {
743768 if err := async.SendAsync(wire.Envelope{Payload: payload}); err != nil {
744769 if errors.Is(err, streamframe.ErrBackpressure) {
745- return ports.BrokerError{Code: ports.BrokerErrorUnavailable}
770+ return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "broker IPC send queue is full"}
746771 }
747772 return transportFailure(err)
748773 }
M internal/adapters/brokeripc/errors.go +10 -1
@@ -131,10 +131,19 @@ func errorDetail(err error) brokerwire.ErrorDetail {
131131 case errors.Is(err, context.Canceled):
132132 return brokerwire.ErrorDetail{Code: ports.BrokerErrorCancelled}
133133 default:
134- return brokerwire.ErrorDetail{Code: ports.BrokerErrorUnavailable}
134+ return brokerwire.ErrorDetail{Code: ports.BrokerErrorUnavailable, Text: boundedBrokerErrorText(err)}
135135 }
136136 }
137137
138+func boundedBrokerErrorText(err error) string {
139+ const max = 256
140+ text := err.Error()
141+ if len(text) > max {
142+ text = text[:max]
143+ }
144+ return text
145+}
146+
138147 // failureFromDetail converts one received detail back into the typed local
139148 // failure. A detail with no code is the absent-detail case and yields nil.
140149 func failureFromDetail(detail brokerwire.ErrorDetail) error {
M internal/adapters/brokeripc/helpers_test.go +44 -0
@@ -46,6 +46,39 @@ func testPolicy() ports.BrokerPolicy {
4646 }
4747 }
4848
49+// openRequest builds one valid control-purpose open request for exactly stream,
50+// with this connection's start-if-needed authorization. After the cutover the
51+// calling service allocates every identity with NextStreamID, so a test that
52+// names a stream always states it explicitly.
53+func openRequest(stream ports.BrokerStreamID) ports.BrokerOpenStreamRequest {
54+ return ports.BrokerOpenStreamRequest{
55+ Purpose: ports.BrokerStreamControl,
56+ Local: true,
57+ Stream: stream,
58+ Policy: testPolicy(),
59+ StartMode: ports.BrokerDaemonStartIfNeeded,
60+ }
61+}
62+
63+// attachmentRequest builds one valid attachment request for exactly stream with
64+// the given admission variant and creation name.
65+func attachmentRequest(stream ports.BrokerStreamID, admission ports.BrokerStreamAdmission, name string) ports.BrokerOpenStreamRequest {
66+ request := openRequest(stream)
67+ request.Purpose = ports.BrokerStreamAttachment
68+ request.Admission = admission
69+ request.Name = name
70+ return request
71+}
72+
73+// nextStreamID reads one fresh identity from the service's only allocator,
74+// exactly as the composed supervisor and broker operations do.
75+func nextStreamID(t *testing.T, service ports.BrokerService) ports.BrokerStreamID {
76+ t.Helper()
77+ stream, err := service.NextStreamID()
78+ require.NoError(t, err)
79+ return stream
80+}
81+
4982 // membershipProbeEndpoint is the endpoint every liveness probe adds through the
5083 // real membership delegation path.
5184 const membershipProbeEndpoint = "probe@host:22"
@@ -367,10 +400,21 @@ type fakeCore struct {
367400 addResult *domain.RemoteRegistration
368401 updateResult *domain.RemoteRegistration
369402 removeRemoved bool
403+
404+ nextStream ports.BrokerStreamID
370405 }
371406
372407 func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id }
373408
409+// NextStreamID is the admitted core connection's only stream allocator, exactly
410+// like the production service: the listener delegates it unchanged.
411+func (c *fakeCore) NextStreamID() (ports.BrokerStreamID, error) {
412+ c.mu.Lock()
413+ defer c.mu.Unlock()
414+ c.nextStream++
415+ return c.nextStream, nil
416+}
417+
374418 func (c *fakeCore) Done() <-chan struct{} { return c.done }
375419
376420 func (c *fakeCore) Err() error { return nil }
M internal/adapters/brokeripc/limits_test.go +14 -9
@@ -258,9 +258,9 @@ func TestAdmissionErrorMapping(t *testing.T) {
258258 require.ErrorIs(t, admissionError(brokerwire.ErrFutureStream), ports.BrokerAdmissionInvalid)
259259 }
260260
261-// TestScopeRequestFillsIdentity proves an absent identity is filled from the
262-// connection, a foreign one is refused as stale, and stream identities advance
263-// strictly.
261+// TestScopeRequestFillsIdentity proves an absent epoch/connection identity is
262+// filled from the connection, a foreign one is refused as stale, and a request
263+// without a stream identity is refused as invalid rather than allocated here.
264264 func TestScopeRequestFillsIdentity(t *testing.T) {
265265 c := &client{
266266 scope: brokerwire.Scope{Epoch: 4, Connection: ports.BrokerConnectionID{4}},
@@ -271,28 +271,33 @@ func TestScopeRequestFillsIdentity(t *testing.T) {
271271 cfg: Config{}.withDefaults(),
272272 transport: nil,
273273 }
274- scoped, err := c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
274+ scoped, err := c.scopeRequest(openRequest(1))
275275 require.NoError(t, err)
276276 require.Equal(t, ports.BrokerEpoch(4), scoped.Epoch)
277277 require.Equal(t, ports.BrokerConnectionID{4}, scoped.Connection)
278278 require.Equal(t, ports.BrokerStreamID(1), scoped.Stream)
279279
280280 _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{
281- Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
281+ Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded,
282282 Connection: ports.BrokerConnectionID{0x11},
283283 })
284284 require.ErrorIs(t, err, ports.BrokerAdmissionStale)
285285
286286 _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{
287- Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), Epoch: 99,
287+ Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded, Epoch: 99,
288288 })
289289 require.ErrorIs(t, err, ErrScopeMismatch)
290290
291- scoped, err = c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 5, Policy: testPolicy()})
291+ scoped, err = c.scopeRequest(openRequest(5))
292292 require.NoError(t, err)
293293 require.Equal(t, ports.BrokerStreamID(5), scoped.Stream)
294- _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 5, Policy: testPolicy()})
295- require.ErrorIs(t, err, ports.BrokerAdmissionStale)
294+
295+ // The adapter never invents a stream identity: an incomplete request is
296+ // refused, and the calling service is the only allocator.
297+ _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{
298+ Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded,
299+ })
300+ require.ErrorIs(t, err, ports.BrokerAdmissionInvalid)
296301 }
297302
298303 // TestSocketPaths proves the endpoint name is distinct from the daemon socket
M internal/adapters/brokeripc/protocol_test.go +3 -0
@@ -158,6 +158,7 @@ func TestNegotiatedChunkCeilingIsEnforced(t *testing.T) {
158158 raw.send(t, brokerwire.OpenStream{
159159 Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1,
160160 Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
161+ StartMode: ports.BrokerDaemonStartIfNeeded,
161162 })
162163 opened, ok := raw.recv(t).(brokerwire.StreamOpened)
163164 require.True(t, ok, "the stream must be established before chunk enforcement matters")
@@ -186,6 +187,7 @@ func TestScopeMismatchOpenStreamIsRefusedAsStale(t *testing.T) {
186187 raw.send(t, brokerwire.OpenStream{
187188 Epoch: scope.Epoch, Connection: ports.BrokerConnectionID{0x7f},
188189 Stream: 1, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
190+ StartMode: ports.BrokerDaemonStartIfNeeded,
189191 })
190192 closed, ok := raw.recv(t).(brokerwire.StreamClosed)
191193 require.True(t, ok, "a mismatched scope must be answered with StreamClosed")
@@ -196,6 +198,7 @@ func TestScopeMismatchOpenStreamIsRefusedAsStale(t *testing.T) {
196198 raw.send(t, brokerwire.OpenStream{
197199 Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1,
198200 Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
201+ StartMode: ports.BrokerDaemonStartIfNeeded,
199202 })
200203 _, ok = raw.recv(t).(brokerwire.StreamOpened)
201204 require.True(t, ok)
M internal/adapters/brokeripc/server.go +16 -1
@@ -899,6 +899,18 @@ func orderlyDisconnect(err error) bool {
899899 // ConnectionID returns the identity assigned to this connection at accept.
900900 func (s *serverSession) ConnectionID() ports.BrokerConnectionID { return s.scope.Connection }
901901
902+// NextStreamID allocates the next logical stream identity of the admitted core
903+// connection. The wire identities are allocated by the calling service and are
904+// carried to this listener unchanged; this delegating form exists so a composed
905+// caller can use the session as a scoped ports.BrokerService. It never
906+// re-allocates a wire identity and performs no I/O.
907+func (s *serverSession) NextStreamID() (ports.BrokerStreamID, error) {
908+ if s == nil {
909+ return 0, ErrSessionClosed
910+ }
911+ return s.core.NextStreamID()
912+}
913+
902914 // Done closes exactly once when this session is terminal: the carriage failed,
903915 // the peer disconnected, or the owner closed it. Err is stable afterwards.
904916 func (s *serverSession) Done() <-chan struct{} { return s.done }
@@ -929,7 +941,10 @@ func (s *serverSession) Subscribe() (ports.BrokerSubscription, error) { return s
929941 // service. The connection's own reader drives the same core through
930942 // openStreamMessage, which additionally bridges the stream onto the wire; this
931943 // method exists so a composed caller can use the session as a scoped
932-// ports.BrokerService.
944+// ports.BrokerService. The stream identity is the one the caller already
945+// allocated (NextStreamID) and is transmitted unchanged: this listener never
946+// re-allocates a wire identity, whether the request arrives over the wire or
947+// through this in-process delegate.
933948 func (s *serverSession) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
934949 if err := s.checkRequestScope(request.Epoch, request.Connection); err != nil {
935950 return nil, err
M internal/adapters/brokeripc/session_test.go +66 -30
@@ -109,9 +109,7 @@ func TestSessionCloseStreamFillsConnectionScope(t *testing.T) {
109109
110110 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
111111 defer cancel()
112- opened, err := session.OpenStream(ctx, ports.BrokerOpenStreamRequest{
113- Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy(),
114- })
112+ opened, err := session.OpenStream(ctx, openRequest(1))
115113 require.NoError(t, err)
116114 require.NotNil(t, opened)
117115
@@ -143,10 +141,7 @@ func TestOpenStreamAdmissionRoundTrips(t *testing.T) {
143141
144142 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
145143 defer cancel()
146- stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{
147- Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed, Name: "work",
148- Local: true, Policy: testPolicy(),
149- })
144+ stream, err := client.OpenStream(ctx, attachmentRequest(1, ports.BrokerAdmissionCreateNamed, "work"))
150145 require.NoError(t, err)
151146 require.NotNil(t, stream)
152147
@@ -157,6 +152,42 @@ func TestOpenStreamAdmissionRoundTrips(t *testing.T) {
157152 require.Equal(t, "work", core.opens[0].Name)
158153 require.Equal(t, ports.BrokerStreamAttachment, core.opens[0].Purpose)
159154 require.True(t, core.opens[0].Local)
155+ require.Equal(t, ports.BrokerDaemonStartIfNeeded, core.opens[0].StartMode)
156+}
157+
158+// TestObservationForcesExistingOnly proves the observation authorization
159+// travels the IPC wire: a control stream may carry start-if-needed, while an
160+// observation must carry existing-only and a spawn-capable observation is
161+// refused before any frame travels.
162+func TestObservationForcesExistingOnly(t *testing.T) {
163+ e := startEndpoint(t, Config{})
164+ client, _ := e.pair()
165+ core := e.authority.last()
166+ require.NotNil(t, core)
167+
168+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
169+ defer cancel()
170+ streamID := nextStreamID(t, client)
171+ observation := openRequest(streamID)
172+ observation.Purpose = ports.BrokerStreamObservation
173+ observation.StartMode = ports.BrokerDaemonExistingOnly
174+ stream, err := client.OpenStream(ctx, observation)
175+ require.NoError(t, err)
176+ require.NoError(t, stream.Close())
177+
178+ // A spawn-capable observation is refused locally: it never reaches the
179+ // admitted core at all.
180+ startable := openRequest(nextStreamID(t, client))
181+ startable.Purpose = ports.BrokerStreamObservation
182+ startable.StartMode = ports.BrokerDaemonStartIfNeeded
183+ _, err = client.OpenStream(ctx, startable)
184+ require.ErrorIs(t, err, ports.BrokerAdmissionInvalid, "observation never authorizes a spawn")
185+
186+ core.mu.Lock()
187+ defer core.mu.Unlock()
188+ require.Len(t, core.opens, 1, "only the existing-only observation reached the core")
189+ require.Equal(t, ports.BrokerStreamObservation, core.opens[0].Purpose)
190+ require.Equal(t, ports.BrokerDaemonExistingOnly, core.opens[0].StartMode)
160191 }
161192
162193 // TestRegistrationAssignsScopeAtAccept proves the client adapter adopts exactly
@@ -304,11 +335,7 @@ func TestOpenStreamCarriesTypedTraffic(t *testing.T) {
304335
305336 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
306337 defer cancel()
307- stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{
308- Purpose: ports.BrokerStreamControl,
309- Local: true,
310- Policy: testPolicy(),
311- })
338+ stream, err := client.OpenStream(ctx, openRequest(1))
312339 require.NoError(t, err)
313340 t.Cleanup(func() { _ = stream.Close() })
314341
@@ -351,11 +378,7 @@ func TestOpenStreamRefusedByCoreReachesClient(t *testing.T) {
351378
352379 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
353380 defer cancel()
354- stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{
355- Purpose: ports.BrokerStreamControl,
356- Local: true,
357- Policy: testPolicy(),
358- })
381+ stream, err := client.OpenStream(ctx, openRequest(1))
359382 require.Error(t, err)
360383 require.Nil(t, stream)
361384 var failure ports.BrokerError
@@ -379,9 +402,9 @@ func TestTerminalStreamFailureFailsOnlyThatStream(t *testing.T) {
379402
380403 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
381404 defer cancel()
382- first, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
405+ first, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client)))
383406 require.NoError(t, err)
384- second, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
407+ second, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client)))
385408 require.NoError(t, err)
386409 t.Cleanup(func() { _ = first.Close(); _ = second.Close() })
387410
@@ -419,7 +442,7 @@ func TestCloseStreamRetiresBothSides(t *testing.T) {
419442
420443 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
421444 defer cancel()
422- stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
445+ stream, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client)))
423446 require.NoError(t, err)
424447 require.NoError(t, client.CloseStream(client.ConnectionID(), 1))
425448
@@ -705,7 +728,7 @@ func TestDisconnectCleanupReleasesEveryOwnedResource(t *testing.T) {
705728
706729 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
707730 defer cancel()
708- _, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
731+ _, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client)))
709732 require.NoError(t, err)
710733 conn := core.logicalConn(1)
711734 require.NotNil(t, conn)
@@ -767,7 +790,7 @@ func TestClientStreamBackpressureSettlesOnlyThatStream(t *testing.T) {
767790
768791 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
769792 defer cancel()
770- stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
793+ stream, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client)))
771794 require.NoError(t, err)
772795 conn := core.logicalConn(1)
773796 require.NotNil(t, conn)
@@ -802,7 +825,7 @@ func TestOrderlyStreamCloseReportsNoError(t *testing.T) {
802825
803826 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
804827 defer cancel()
805- stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
828+ stream, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client)))
806829 require.NoError(t, err)
807830 require.NoError(t, stream.Close())
808831 select {
@@ -825,23 +848,36 @@ func TestScopeIdentityFencing(t *testing.T) {
825848 defer cancel()
826849 foreign := ports.BrokerConnectionID{0x99}
827850 _, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{
828- Purpose: ports.BrokerStreamControl, Local: true, Connection: foreign, Policy: testPolicy(),
851+ Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Connection: foreign, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded,
829852 })
830853 require.ErrorIs(t, err, ports.BrokerAdmissionStale)
831854
832855 _, err = client.OpenStream(ctx, ports.BrokerOpenStreamRequest{
833- Purpose: ports.BrokerStreamControl, Local: true, Epoch: e.epoch + 1, Policy: testPolicy(),
856+ Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Epoch: e.epoch + 1, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded,
834857 })
835858 require.ErrorIs(t, err, ErrScopeMismatch)
836859 var stale ports.BrokerError
837860 require.ErrorAs(t, err, &stale)
838861 require.Equal(t, ports.BrokerErrorStaleEpoch, stale.Code)
839862
840- first, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
863+ // A concurrent lower identity is admitted: an ID allocated before a higher
864+ // one can legitimately arrive after it.
865+ first, err := client.OpenStream(ctx, openRequest(2))
841866 require.NoError(t, err)
842867 t.Cleanup(func() { _ = first.Close() })
843- _, err = client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy()})
844- require.ErrorIs(t, err, ports.BrokerAdmissionStale)
868+ second, err := client.OpenStream(ctx, openRequest(1))
869+ require.NoError(t, err, "a concurrent lower identity is admitted, not refused as stale")
870+ t.Cleanup(func() { _ = second.Close() })
871+ // Replaying a consumed identity is refused, and so is an identity evicted
872+ // past the anti-replay window.
873+ _, err = client.OpenStream(ctx, openRequest(1))
874+ require.ErrorIs(t, err, ports.BrokerAdmissionStale, "a replayed identity is stale")
875+ _, err = client.OpenStream(ctx, openRequest(ports.BrokerStreamWindowSize+3))
876+ require.NoError(t, err)
877+ _, err = client.OpenStream(ctx, openRequest(1))
878+ require.ErrorIs(t, err, ports.BrokerAdmissionStale, "an identity evicted from the window is stale")
879+ _, err = client.OpenStream(ctx, openRequest(0))
880+ require.ErrorIs(t, err, ports.BrokerAdmissionInvalid, "a zero identity is never admitted")
845881 }
846882
847883 // TestRequestReconcileIsBoundedHint proves the reconcile hint is sent only for an
@@ -881,7 +917,7 @@ func TestCoreErrorSurfacesAsTypedFailure(t *testing.T) {
881917
882918 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
883919 defer cancel()
884- stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
920+ stream, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client)))
885921 require.NoError(t, err)
886922 t.Cleanup(func() { _ = stream.Close() })
887923
@@ -906,7 +942,7 @@ func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) {
906942
907943 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
908944 defer cancel()
909- stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})
945+ stream, err := client.OpenStream(ctx, openRequest(nextStreamID(t, client)))
910946 require.NoError(t, err)
911947 conn := core.logicalConn(1)
912948 require.NotNil(t, conn)
M internal/adapters/brokeripc/stream.go +1 -0
@@ -208,6 +208,7 @@ func (s *serverSession) startStream(m brokerwire.OpenStream) {
208208 Connection: m.Connection, Stream: m.Stream,
209209 Endpoint: m.Endpoint, Registration: m.Registration,
210210 Target: m.Target, Env: m.Env, Policy: m.Policy,
211+ StartMode: m.StartMode,
211212 }
212213 s.wg.Add(1)
213214 go func() {
M internal/adapters/brokerstore/legacy.go +12 -3
@@ -110,12 +110,17 @@ func migrate(o Options) (recovery, error) {
110110 if r.Cache, err = source(o.LegacyCache); err != nil {
111111 return r, err
112112 }
113- r.State.Manifest = manifest{Version: 1, Sources: []manifestSource{
113+ r.State.Manifest = manifest{Version: 1, ImportVersion: 1, ImportCompleted: true, Sources: []manifestSource{
114114 {Label: manifestMembership, Present: o.LegacyHosts != "", SHA256: digest(r.Hosts)},
115115 {Label: manifestObservations, Present: o.LegacyCache != "", SHA256: digest(r.Cache)},
116116 }}
117117 r.State.Hosts = ports.BrokerHosts{Revision: 1}
118- if o.LegacyHosts != "" {
118+ if o.InitialImportProvided {
119+ r.State.Hosts.Hosts = ports.CloneBrokerHostRecords(o.InitialHosts)
120+ if err := ports.ValidateBrokerHostRecords(r.State.Hosts.Hosts); err != nil && len(r.State.Hosts.Hosts) != 0 {
121+ return r, fmt.Errorf("brokerstore: initial import: %w", err)
122+ }
123+ } else if o.LegacyHosts != "" {
119124 r.State.Hosts.Hosts, err = decodeHosts(r.Hosts, o.Policies)
120125 if err != nil {
121126 return r, err
@@ -273,7 +278,11 @@ func decodeHosts(raw []byte, policies map[string]ports.BrokerPolicy) ([]ports.Br
273278 return nil, err
274279 }
275280 indices[record.Endpoint] = len(out)
276- out = append(out, ports.BrokerHostRecord{Registration: reg, Pinned: group == 0, Learned: group == 1, Policy: policy})
281+ route, err := ports.BrokerRouteForTransport(policy.Transport, reg.Endpoint)
282+ if err != nil {
283+ return nil, fmt.Errorf("brokerstore: upgrade route for %q: %w", record.Endpoint, err)
284+ }
285+ out = append(out, ports.BrokerHostRecord{Registration: reg, Pinned: group == 0, Learned: group == 1, Policy: policy, Route: route})
277286 }
278287 }
279288 return out, nil
A internal/adapters/brokerstore/routes_test.go +259 -0
@@ -0,0 +1,259 @@
1+package brokerstore
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "testing"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+// canonicalRoutes are the exact routes production derives for the closed remote
17+// policy transport vocabulary the fixtures use.
18+func canonicalQUICRoute(endpoint string) ports.BrokerRouteSpec {
19+ return ports.BrokerRouteSpec{Kind: ports.BrokerRouteSSHQUIC, Target: endpoint, Argv: []string{"vev", "_broker-mux-quic-bootstrap", "--production"}}
20+}
21+
22+// TestInitialImportEmptyCompletesAndNeverReimports pins the explicit initial
23+// import seam: when the caller supplies an empty initial membership, the import
24+// is recorded as completed with no records, and a later reopen never falls back
25+// to re-importing the legacy source that is still named in Options.
26+func TestInitialImportEmptyCompletesAndNeverReimports(t *testing.T) {
27+ dir := filepath.Join(t.TempDir(), "broker")
28+ o := options(t)
29+ o.Dir = dir
30+ o.LegacyCache = ""
31+ o.InitialHosts = nil
32+ o.InitialImportProvided = true
33+
34+ store, err := Open(o)
35+ require.NoError(t, err)
36+ hosts, err := store.LoadHosts()
37+ require.NoError(t, err)
38+ require.Empty(t, hosts.Hosts, "an empty initial import seeds no membership")
39+
40+ raw, err := readBounded(filepath.Join(dir, "recovery.json"))
41+ require.NoError(t, err)
42+ var record recovery
43+ require.NoError(t, strict(raw, &record))
44+ require.Equal(t, 1, record.State.Manifest.ImportVersion)
45+ require.True(t, record.State.Manifest.ImportCompleted, "the initial import is completed even when empty")
46+
47+ // Remove-all CAS-verifies the empty authority and still commits.
48+ require.NoError(t, store.ReplaceHosts(hosts.Revision, nil))
49+ removed, err := store.LoadHosts()
50+ require.NoError(t, err)
51+ require.Empty(t, removed.Hosts)
52+ require.Equal(t, hosts.Revision+1, removed.Revision)
53+ require.NoError(t, store.Close())
54+
55+ // Reopening names the legacy source again, but state.json exists, so no
56+ // migration runs: the empty, completed import stays authoritative.
57+ store, err = Open(o)
58+ require.NoError(t, err)
59+ defer store.Close()
60+ again, err := store.LoadHosts()
61+ require.NoError(t, err)
62+ require.Empty(t, again.Hosts, "a supplied legacy source is never re-imported after the import completed")
63+ require.Equal(t, removed.Revision, again.Revision)
64+}
65+
66+// TestRecoveryRetainsRoutes proves the retained recovery record carries the
67+// canonical routes as durable authority: reopening from recovery alone (state.json
68+// removed) restores the same routed membership.
69+func TestRecoveryRetainsRoutes(t *testing.T) {
70+ o := options(t)
71+ store, err := Open(o)
72+ require.NoError(t, err)
73+ require.NoError(t, store.Close())
74+
75+ raw, err := readBounded(filepath.Join(o.Dir, "recovery.json"))
76+ require.NoError(t, err)
77+ var record recovery
78+ require.NoError(t, strict(raw, &record))
79+ require.Len(t, record.State.Hosts.Hosts, 1)
80+ require.Equal(t, canonicalQUICRoute("user@arch"), record.State.Hosts.Hosts[0].Route)
81+
82+ // Drop the committed state so the recovery record is the only source.
83+ require.NoError(t, os.Remove(filepath.Join(o.Dir, "state.json")))
84+ o.LegacyHosts, o.LegacyCache, o.Policies = "", "", nil
85+ reopened, err := Open(o)
86+ require.NoError(t, err)
87+ defer reopened.Close()
88+ hosts, err := reopened.LoadHosts()
89+ require.NoError(t, err)
90+ require.Len(t, hosts.Hosts, 1)
91+ require.Equal(t, canonicalQUICRoute("user@arch"), hosts.Hosts[0].Route)
92+}
93+
94+// TestCorruptRouteStateFailsClosed proves a committed state whose route is
95+// invalid is never silently replaced or repaired: Open reports invalid durable
96+// state and leaves the exact bytes it read in place for offline recovery.
97+func TestCorruptRouteStateFailsClosed(t *testing.T) {
98+ o := options(t)
99+ store, err := Open(o)
100+ require.NoError(t, err)
101+ require.NoError(t, store.Close())
102+
103+ statePath := filepath.Join(o.Dir, "state.json")
104+ raw, err := os.ReadFile(statePath)
105+ require.NoError(t, err)
106+ poisoned := bytes.Replace(raw, []byte(`"ssh-quic"`), []byte(`"bogus"`), 1)
107+ require.NotEqual(t, raw, poisoned, "the fixture must carry a route kind to corrupt")
108+ require.NoError(t, os.WriteFile(statePath, poisoned, 0600))
109+
110+ reopened, err := Open(o)
111+ require.Error(t, err)
112+ require.Nil(t, reopened)
113+ require.ErrorIs(t, err, ErrInvalidState)
114+ require.ErrorIs(t, err, ports.ErrBrokerStoreInvalidState)
115+
116+ after, err := os.ReadFile(statePath)
117+ require.NoError(t, err)
118+ require.Equal(t, poisoned, after, "invalid durable state is never silently replaced")
119+}
120+
121+// TestLegacyRemoteUnixAndUnknownTransportRefused proves the migration route
122+// upgrade is closed over the approved remote vocabulary: a legacy policy naming
123+// a unix or unknown transport is refused before any state is committed, so a
124+// legacy local route can never become broker membership.
125+func TestLegacyRemoteUnixAndUnknownTransportRefused(t *testing.T) {
126+ for _, tc := range []struct {
127+ name string
128+ transport string
129+ }{
130+ {name: "unix", transport: "unix"},
131+ {name: "tcp", transport: "tcp"},
132+ {name: "ssh", transport: "ssh"},
133+ {name: "empty", transport: ""},
134+ } {
135+ t.Run(tc.name, func(t *testing.T) {
136+ o := options(t)
137+ policy := testPolicy()
138+ policy.Transport = tc.transport
139+ o.Policies = map[string]ports.BrokerPolicy{"user@arch": policy}
140+ store, err := Open(o)
141+ require.Error(t, err)
142+ require.Nil(t, store)
143+ _, err = os.Stat(filepath.Join(o.Dir, "state.json"))
144+ require.True(t, os.IsNotExist(err), "a refused legacy transport never commits state")
145+ })
146+ }
147+}
148+
149+// TestRouteUpgradeIsAtomicAcrossReopen proves the pre-route upgrade is a durable
150+// migration: a state written without routes is upgraded exactly once, a fault at
151+// any upgrade boundary never leaves an unreadable state behind, and reopening
152+// completes the upgrade so the restored membership carries canonical routes.
153+func TestRouteUpgradeIsAtomicAcrossReopen(t *testing.T) {
154+ // seedStripped opens and closes a valid routed store, then rewrites its
155+ // committed state without routes, exactly as a pre-route process would have
156+ // written it. Each case gets its own directory so a completed upgrade in one
157+ // case never hides the migration in the next.
158+ seedStripped := func(t *testing.T) string {
159+ t.Helper()
160+ dir := filepath.Join(t.TempDir(), "broker")
161+ o := options(t)
162+ o.Dir = dir
163+ store, err := Open(o)
164+ require.NoError(t, err)
165+ require.NoError(t, store.Close())
166+ statePath := filepath.Join(dir, "state.json")
167+ raw, err := os.ReadFile(statePath)
168+ require.NoError(t, err)
169+ var legacyState state
170+ require.NoError(t, strict(raw, &legacyState))
171+ require.Len(t, legacyState.Hosts.Hosts, 1)
172+ legacyState.Hosts.Hosts[0].Route = ports.BrokerRouteSpec{}
173+ stripped, err := json.Marshal(legacyState)
174+ require.NoError(t, err)
175+ require.NoError(t, os.WriteFile(statePath, stripped, 0600))
176+ return dir
177+ }
178+
179+ for _, point := range []string{"recovery.json:write", "state.json:write", "state.json:rename", "state.json:dirsync"} {
180+ t.Run(point, func(t *testing.T) {
181+ dir := seedStripped(t)
182+ fault := errors.New("power loss")
183+ failed := Options{Dir: dir, Fault: func(p string) error {
184+ if p == point {
185+ return fault
186+ }
187+ return nil
188+ }}
189+ failedStore, err := Open(failed)
190+ require.ErrorIs(t, err, fault)
191+ require.Nil(t, failedStore)
192+
193+ // The upgrade is atomic: reopening without the fault always yields a
194+ // readable, routed store, whether or not the failed attempt renamed.
195+ store, err := Open(Options{Dir: dir})
196+ require.NoError(t, err)
197+ hosts, err := store.LoadHosts()
198+ require.NoError(t, err)
199+ require.Len(t, hosts.Hosts, 1)
200+ require.Equal(t, canonicalQUICRoute("user@arch"), hosts.Hosts[0].Route)
201+ require.NoError(t, hosts.Validate())
202+ require.NoError(t, store.Close())
203+
204+ // The upgrade is idempotent: a further reopen keeps the same authority.
205+ store, err = Open(Options{Dir: dir})
206+ require.NoError(t, err)
207+ defer store.Close()
208+ again, err := store.LoadHosts()
209+ require.NoError(t, err)
210+ require.Equal(t, hosts, again)
211+ })
212+ }
213+}
214+
215+// TestRouteChangeRequiresFreshGenerationAndRefusesOldFence proves a policy
216+// change that re-routes an endpoint is admitted only with a fresh registration
217+// generation, is persisted with the canonical route, and fences the retired
218+// registration: a publication bound to the old registration is refused.
219+func TestRouteChangeRequiresFreshGenerationAndRefusesOldFence(t *testing.T) {
220+ o := options(t)
221+ store, err := Open(o)
222+ require.NoError(t, err)
223+ defer store.Close()
224+ h, err := store.LoadHosts()
225+ require.NoError(t, err)
226+ require.Len(t, h.Hosts, 1)
227+ base := h.Hosts[0]
228+ require.Equal(t, canonicalQUICRoute(base.Registration.Endpoint), base.Route)
229+
230+ stdio := base.Policy
231+ stdio.Transport = "stdio"
232+
233+ // A re-route without a fresh generation is refused as a stale authority
234+ // change: a queued observation must never be accepted under the new route.
235+ changed := append([]ports.BrokerHostRecord(nil), h.Hosts...)
236+ changed[0].Policy = stdio
237+ require.ErrorIs(t, store.ReplaceHosts(h.Revision, changed), ErrStale)
238+
239+ // The same change with a fresh generation is accepted and durable, with the
240+ // route the mutation path derives from the new transport.
241+ changed[0].Registration.Generation++
242+ changed[0].Route = ports.BrokerRouteSpec{Kind: ports.BrokerRouteSSHStdio, Target: base.Registration.Endpoint, Argv: []string{"vev", "_broker-mux-stdio", "--production"}}
243+ require.NoError(t, store.ReplaceHosts(h.Revision, changed))
244+ after, err := store.LoadHosts()
245+ require.NoError(t, err)
246+ require.Equal(t, base.Registration.Generation+1, after.Hosts[0].Registration.Generation)
247+ require.Equal(t, stdio, after.Hosts[0].Policy)
248+ require.Equal(t, changed[0].Route, after.Hosts[0].Route)
249+
250+ // The retired registration is fenced: a publication naming it is refused even
251+ // though the endpoint is still live under a newer generation.
252+ retired := ports.BrokerDaemonObservation{
253+ Endpoint: base.Registration.Endpoint,
254+ DisplayOrigin: domain.RemoteDisplayOrigin(base.Registration.Endpoint),
255+ Registration: base.Registration,
256+ Availability: domain.RemoteAvailabilityReachable,
257+ }
258+ require.ErrorIs(t, store.Store(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{retired}}), ErrStale)
259+}
M internal/adapters/brokerstore/store.go +29 -5
@@ -13,6 +13,7 @@ import (
1313 "fmt"
1414 "os"
1515 "path/filepath"
16+ "reflect"
1617 "sync"
1718 "syscall"
1819
@@ -68,8 +69,10 @@ type manifestSource struct {
6869 }
6970
7071 type manifest struct {
71- Version int
72- Sources []manifestSource
72+ Version int
73+ Sources []manifestSource
74+ ImportVersion int
75+ ImportCompleted bool
7376 }
7477
7578 // source returns the manifest entry for one generic role label.
@@ -88,6 +91,8 @@ func (m manifest) source(label manifestSourceLabel) (manifestSource, bool) {
8891 type Options struct {
8992 Dir, LegacyHosts, LegacyCache string
9093 Policies map[string]ports.BrokerPolicy
94+ InitialHosts []ports.BrokerHostRecord
95+ InitialImportProvided bool
9196 Fault func(string) error
9297 }
9398
@@ -183,7 +188,23 @@ func Open(o Options) (*Store, error) {
183188 if err != nil {
184189 return nil, err
185190 }
186- if err = validate(s.state); err != nil {
191+ upgraded, upgradeErr := ports.UpgradeBrokerHostRoutes(s.state.Hosts.Hosts)
192+ if upgradeErr != nil {
193+ return nil, upgradeErr
194+ }
195+ if !reflect.DeepEqual(upgraded, s.state.Hosts.Hosts) {
196+ next := s.state
197+ next.Hosts.Hosts = upgraded
198+ next.Manifest.ImportVersion = 1
199+ next.Manifest.ImportCompleted = true
200+ recovery := recovery{State: next}
201+ if err = s.write("recovery.json", recovery); err != nil {
202+ return nil, err
203+ }
204+ if err = s.commit(next); err != nil {
205+ return nil, err
206+ }
207+ } else if err = validate(s.state); err != nil {
187208 return nil, err
188209 }
189210 ok = true
@@ -240,7 +261,7 @@ func (s *Store) LoadHosts() (ports.BrokerHosts, error) {
240261 return ports.BrokerHosts{}, err
241262 }
242263 h := s.state.Hosts
243- h.Hosts = append([]ports.BrokerHostRecord(nil), h.Hosts...)
264+ h.Hosts = ports.CloneBrokerHostRecords(h.Hosts)
244265 return h, nil
245266 }
246267 func (s *Store) Load() (ports.BrokerSnapshot, error) {
@@ -267,7 +288,7 @@ func (s *Store) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) er
267288 return ErrStale
268289 }
269290 next := s.state
270- next.Hosts = ports.BrokerHosts{Revision: expected + 1, Hosts: append([]ports.BrokerHostRecord(nil), hosts...)}
291+ next.Hosts = ports.BrokerHosts{Revision: expected + 1, Hosts: ports.CloneBrokerHostRecords(hosts)}
271292 // Policy changes require a new registration generation as well; otherwise
272293 // an already queued observation could be accepted under changed trust.
273294 for _, h := range hosts {
@@ -350,6 +371,9 @@ func validate(st state) error {
350371 if st.Manifest.Version != 1 {
351372 return invalid("unsupported manifest")
352373 }
374+ if st.Manifest.ImportVersion != 1 || !st.Manifest.ImportCompleted {
375+ return invalid("initial import is incomplete")
376+ }
353377 labels := make(map[manifestSourceLabel]bool, len(st.Manifest.Sources))
354378 for _, source := range st.Manifest.Sources {
355379 if labels[source.Label] {
M internal/adapters/brokerwire/codec.go +38 -1
@@ -499,6 +499,7 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
499499 Connection: m.Connection, Stream: m.Stream,
500500 Endpoint: m.Endpoint, Registration: m.Registration,
501501 Target: m.Target, Env: m.Env, Policy: m.Policy,
502+ StartMode: m.StartMode,
502503 }
503504 if err := request.Validate(); err != nil {
504505 // Distinguish bound refusals from semantic refusals.
@@ -530,6 +531,10 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
530531 if err != nil {
531532 return nil, err
532533 }
534+ startMode, err := startModeToWire(m.StartMode)
535+ if err != nil {
536+ return nil, err
537+ }
533538 out := &wire.OpenStream{
534539 Ref: refToWire(m.Epoch, m.Connection, m.Stream),
535540 Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint,
@@ -538,7 +543,7 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
538543 // The admission taxonomy mirrors the port values (0 none, 1 exact,
539544 // 2 create named, 3 create ephemeral); the name travels only for the
540545 // create-named variant.
541- Admission: admission, Name: m.Name,
546+ Admission: admission, Name: m.Name, StartMode: startMode,
542547 }
543548 if !m.Local {
544549 out.Registration = registrationToWire(m.Registration)
@@ -584,6 +589,32 @@ func admissionFromWire(value uint32) (ports.BrokerStreamAdmission, error) {
584589 }
585590 }
586591
592+// startModeToWire maps the closed daemon-start taxonomy onto its wire code.
593+// The zero value is refused rather than encoded, so a peer never has to guess
594+// whether an absent mode authorized a spawn.
595+func startModeToWire(mode ports.BrokerDaemonStartMode) (uint32, error) {
596+ switch mode {
597+ case ports.BrokerDaemonExistingOnly:
598+ return 1, nil
599+ case ports.BrokerDaemonStartIfNeeded:
600+ return 2, nil
601+ default:
602+ return 0, errConvertRange
603+ }
604+}
605+
606+// startModeFromWire maps a wire daemon-start code onto the closed taxonomy.
607+func startModeFromWire(value uint32) (ports.BrokerDaemonStartMode, error) {
608+ switch value {
609+ case 1:
610+ return ports.BrokerDaemonExistingOnly, nil
611+ case 2:
612+ return ports.BrokerDaemonStartIfNeeded, nil
613+ default:
614+ return 0, errConvertRange
615+ }
616+}
617+
587618 func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
588619 var out OpenStream
589620 if message == nil {
@@ -608,6 +639,10 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
608639 if err != nil {
609640 return OpenStream{}, ErrInvalidMessage
610641 }
642+ startMode, err := startModeFromWire(message.GetStartMode())
643+ if err != nil {
644+ return OpenStream{}, ErrInvalidMessage
645+ }
611646 local := message.GetLocal()
612647 var registration domain.RemoteRegistration
613648 if !local {
@@ -647,6 +682,7 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
647682 Local: local,
648683 Endpoint: endpoint,
649684 Registration: registration, Target: target, Env: env, Policy: policy,
685+ StartMode: startMode,
650686 }
651687 request := ports.BrokerOpenStreamRequest{
652688 Epoch: candidate.Epoch, Purpose: candidate.Purpose,
@@ -654,6 +690,7 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
654690 Connection: candidate.Connection, Stream: candidate.Stream,
655691 Endpoint: candidate.Endpoint, Registration: candidate.Registration,
656692 Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,
693+ StartMode: candidate.StartMode,
657694 }
658695 if err := request.Validate(); err != nil {
659696 return OpenStream{}, ErrInvalidMessage
M internal/adapters/brokerwire/codec_test.go +74 -8
@@ -354,7 +354,7 @@ func TestBrokerClientVariants(t *testing.T) {
354354 {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, 105},
355355 {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, 106},
356356 {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107},
357- {"open_stream", OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"TERM=xterm"}, Policy: testPolicy()}, 108},
357+ {"open_stream", OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"TERM=xterm"}, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}, 108},
358358 {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109},
359359 {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110},
360360 {"update_host_policy", UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration(), Policy: testPolicy()}, 111},
@@ -384,6 +384,9 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
384384 return OpenStream{
385385 Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment,
386386 Endpoint: "dev@host:22", Registration: testRegistration(), Policy: testPolicy(),
387+ // An attachment or creation always carries the explicit
388+ // start-if-needed authorization; the zero mode is refused.
389+ StartMode: ports.BrokerDaemonStartIfNeeded,
387390 }
388391 }
389392 roundTrips := []struct {
@@ -409,7 +412,7 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
409412 }()},
410413 {"control carries none", OpenStream{
411414 Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl,
412- Local: true, Policy: testPolicy(),
415+ Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded,
413416 }},
414417 }
415418 for _, tc := range roundTrips {
@@ -455,7 +458,17 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
455458 }(),
456459 "control with admission": OpenStream{
457460 Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl,
458- Local: true, Policy: testPolicy(), Admission: ports.BrokerAdmissionExact,
461+ Local: true, Policy: testPolicy(), Admission: ports.BrokerAdmissionExact, StartMode: ports.BrokerDaemonStartIfNeeded,
462+ },
463+ "attachment with zero start mode": func() OpenStream { m := exact(); m.StartMode = 0; return m }(),
464+ "attachment with existing-only start mode": func() OpenStream {
465+ m := exact()
466+ m.StartMode = ports.BrokerDaemonExistingOnly
467+ return m
468+ }(),
469+ "observation with start-if-needed start mode": OpenStream{
470+ Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamObservation,
471+ Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded,
459472 },
460473 }
461474 for name, message := range cases {
@@ -480,6 +493,59 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
480493 _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
481494 require.ErrorIs(t, err, ErrInvalidMessage)
482495 })
496+
497+ t.Run("start mode round-trips and is never defaulted", func(t *testing.T) {
498+ // The mode round-trips with the purpose it is legal for: an existing-only
499+ // authorization belongs to control/observation, a start-if-needed one to
500+ // attachment/control.
501+ control := func(mode ports.BrokerDaemonStartMode) OpenStream {
502+ return OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: mode}
503+ }
504+ for _, message := range []OpenStream{
505+ control(ports.BrokerDaemonExistingOnly),
506+ control(ports.BrokerDaemonStartIfNeeded),
507+ func() OpenStream {
508+ m := base()
509+ m.Admission = ports.BrokerAdmissionExact
510+ m.Target = testTarget()
511+ m.StartMode = ports.BrokerDaemonStartIfNeeded
512+ return m
513+ }(),
514+ } {
515+ decoded, err := DecodeClient(mustEncodeClient(t, message), testEnvelopeCeiling, testChunkCeiling)
516+ require.NoError(t, err)
517+ admitting, ok := decoded.(OpenStream)
518+ require.True(t, ok)
519+ require.Equal(t, message.StartMode, admitting.StartMode)
520+ }
521+ // An absent wire mode is refused, never widened into a spawn-capable
522+ // default, and an unknown code is refused too.
523+ message := base()
524+ message.Admission = ports.BrokerAdmissionExact
525+ message.Target = testTarget()
526+ raw := mustEncodeClient(t, message)
527+ envelope := &wire.BrokerClientEnvelope{}
528+ require.NoError(t, proto.Unmarshal(raw, envelope))
529+ envelope.GetOpenStream().StartMode = 0
530+ mutated, err := proto.Marshal(envelope)
531+ require.NoError(t, err)
532+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
533+ require.ErrorIs(t, err, ErrInvalidMessage)
534+ envelope.GetOpenStream().StartMode = 3
535+ mutated, err = proto.Marshal(envelope)
536+ require.NoError(t, err)
537+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
538+ require.ErrorIs(t, err, ErrInvalidMessage)
539+ // An observation that authorized a spawn is refused rather than narrowed.
540+ envelope.GetOpenStream().Purpose = 3
541+ envelope.GetOpenStream().StartMode = 2
542+ envelope.GetOpenStream().Target = nil
543+ envelope.GetOpenStream().Admission = 0
544+ mutated, err = proto.Marshal(envelope)
545+ require.NoError(t, err)
546+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
547+ require.ErrorIs(t, err, ErrInvalidMessage)
548+ })
483549 }
484550
485551 // TestBrokerSnapshotDaemonByteForByte proves the daemon and session parts are
@@ -949,7 +1015,7 @@ func TestBrokerValidationNegatives(t *testing.T) {
9491015 require.ErrorIs(t, err, ErrInvalidMessage)
9501016 })
9511017 t.Run("taxonomy switches", func(t *testing.T) {
952- raw := mustEncodeClient(t, OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()})
1018+ raw := mustEncodeClient(t, OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded})
9531019 envelope := &wire.BrokerClientEnvelope{}
9541020 require.NoError(t, wire.ScanEnvelope(envelope, raw))
9551021 require.NoError(t, proto.Unmarshal(raw, envelope))
@@ -981,11 +1047,11 @@ func TestBrokerValidationNegatives(t *testing.T) {
9811047 require.ErrorIs(t, err, ErrInvalidMessage)
9821048 })
9831049 t.Run("env without equals refused", func(t *testing.T) {
984- _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"NOEQUALS"}, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
1050+ _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"NOEQUALS"}, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}, testEnvelopeCeiling, testChunkCeiling)
9851051 require.ErrorIs(t, err, ErrInvalidMessage)
9861052 })
9871053 t.Run("invalid policy refused", func(t *testing.T) {
988- _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: ports.BrokerPolicy{}}, testEnvelopeCeiling, testChunkCeiling)
1054+ _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: ports.BrokerPolicy{}, StartMode: ports.BrokerDaemonStartIfNeeded}, testEnvelopeCeiling, testChunkCeiling)
9891055 require.ErrorIs(t, err, ErrInvalidMessage)
9901056 })
9911057 t.Run("snapshot revision zero refused", func(t *testing.T) {
@@ -1075,7 +1141,7 @@ func TestBrokerCorruptInputSmoke(t *testing.T) {
10751141 mustEncodeClient(t, AddHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x21), Endpoint: "dev@host:22", Policy: testPolicy()}),
10761142 mustEncodeClient(t, RemoveHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x22), Registration: testRegistration()}),
10771143 mustEncodeClient(t, UpdateHostPolicy{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x23), Registration: testRegistration(), Policy: testPolicy()}),
1078- mustEncodeClient(t, OpenStream{Epoch: 7, Connection: testConnectionID(2), Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}),
1144+ mustEncodeClient(t, OpenStream{Epoch: 7, Connection: testConnectionID(2), Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}),
10791145 mustEncodeServer(t, SnapshotPart{Epoch: 7, Connection: testConnectionID(2), Generation: 1, Revision: 1, Index: 0, Part: SnapshotEnd{}}),
10801146 mustEncodeServer(t, OperationResult{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x24), Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true}),
10811147 mustEncodeServer(t, Shutdown{Epoch: 7, Connection: testConnectionID(2), Reason: ShutdownIdleExit}),
@@ -1108,7 +1174,7 @@ func fuzzClientSeeds(t testing.TB) [][]byte {
11081174 AddHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x11), Endpoint: "dev@host:22", Policy: testPolicy()},
11091175 RemoveHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x12), Registration: testRegistration()},
11101176 UpdateHostPolicy{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x13), Registration: testRegistration(), Policy: testPolicy()},
1111- OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()},
1177+ OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded},
11121178 ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")},
11131179 CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3},
11141180 } {
M internal/adapters/brokerwire/messages.go +4 -1
@@ -122,7 +122,9 @@ func (Reconcile) brokerClientMessage() {}
122122 // stream to the owning daemon. Env is the per-request session environment
123123 // and is never inherited from the broker process environment. Admission
124124 // selects the attachment admission variant; Name is the validated session
125-// name for create-named and is empty otherwise.
125+// name for create-named and is empty otherwise. StartMode is the explicit
126+// daemon-start authorization carried to the transport; it is never zero on an
127+// admitted request.
126128 type OpenStream struct {
127129 Epoch ports.BrokerEpoch
128130 Connection ports.BrokerConnectionID
@@ -136,6 +138,7 @@ type OpenStream struct {
136138 Target protocol.ExactSessionTarget
137139 Env []string
138140 Policy ports.BrokerPolicy
141+ StartMode ports.BrokerDaemonStartMode
139142 }
140143
141144 func (OpenStream) brokerClientMessage() {}
M internal/adapters/brokerwire/state.go +27 -12
@@ -81,9 +81,9 @@ var (
8181 ErrUnknownOperation = errors.New("brokerwire: unknown operation")
8282 // ErrInvalidStream reports a zero stream identity.
8383 ErrInvalidStream = errors.New("brokerwire: invalid broker stream")
84- // ErrStreamIDReused reports a stream identity that is not strictly above
85- // every allocated identity: stream IDs strictly increase and are never
86- // reused.
84+ // ErrStreamIDReused reports a stream identity that is already consumed or
85+ // evicted outside the bounded anti-replay window. Stream identities are
86+ // allocated by the calling service and are never reused.
8787 ErrStreamIDReused = errors.New("brokerwire: stream ID is not fresh")
8888 // ErrFutureStream reports a frame for a stream identity that was never
8989 // allocated.
@@ -718,17 +718,22 @@ const (
718718 )
719719
720720 // StreamTracker bounds the logical streams of one connection scope. Stream
721-// identities strictly increase and are never reused. At most MaxStreams
722-// streams are opening or open at once; at most MaxRetiredStreams retired
723-// records are retained, so an older late frame is still classified as
724-// retired rather than mistaken for a future stream. It is safe for
725-// concurrent use.
721+// identities are allocated by the calling service and may arrive slightly out
722+// of order when two opens race, so admission uses one bounded anti-replay
723+// window instead of a bare high-water mark: every unconsumed identity inside
724+// the window is admitted exactly once, a duplicate is refused, and an identity
725+// that trails the newest admitted identity by the window size is stale. At most
726+// MaxStreams streams are opening or open at once; at most MaxRetiredStreams
727+// retired records are retained, so an older late frame is still classified as
728+// retired rather than mistaken for a future stream. It is safe for concurrent
729+// use.
726730 type StreamTracker struct {
727731 mu sync.Mutex
728732 scope Scope
729733 states map[ports.BrokerStreamID]StreamState
730734 retired []ports.BrokerStreamID
731735 highest ports.BrokerStreamID
736+ window ports.BrokerStreamWindow
732737 active int
733738 }
734739
@@ -743,8 +748,12 @@ func newStreamTracker(scope Scope) (*StreamTracker, error) {
743748 return &StreamTracker{scope: scope, states: make(map[ports.BrokerStreamID]StreamState, MaxStreams)}, nil
744749 }
745750
746-// Open allocates one fresh logical stream in Opening state. The identity must
747-// be strictly above every allocated identity and must not already be in use.
751+// Open allocates one fresh logical stream in Opening state. The identity is
752+// admitted on this connection's bounded anti-replay window: it must be
753+// unconsumed and inside the window, and it must not already be in use. An
754+// identity allocated by a concurrent open is therefore admitted even when it
755+// arrives just after a higher one, while a replay and an identity evicted past
756+// the window are both refused.
748757 func (t *StreamTracker) Open(scope Scope, stream ports.BrokerStreamID) error {
749758 if t == nil {
750759 return ErrConnectionClosed
@@ -757,14 +766,19 @@ func (t *StreamTracker) Open(scope Scope, stream ports.BrokerStreamID) error {
757766 if err := stream.Validate(); err != nil {
758767 return ErrInvalidStream
759768 }
760- if stream <= t.highest {
769+ if err := t.window.Admit(stream); err != nil {
770+ // A duplicate or an identity evicted from the window is stale rather
771+ // than malformed, so the caller can classify it as a never-reused
772+ // identity instead of a protocol violation.
761773 return ErrStreamIDReused
762774 }
763775 if t.active >= MaxStreams {
764776 return ErrTooManyStreams
765777 }
766778 t.states[stream] = StreamOpening
767- t.highest = stream
779+ if stream > t.highest {
780+ t.highest = stream
781+ }
768782 t.active++
769783 return nil
770784 }
@@ -909,5 +923,6 @@ func (t *StreamTracker) reset() {
909923 t.states = make(map[ports.BrokerStreamID]StreamState, MaxStreams)
910924 t.retired = nil
911925 t.highest = 0
926+ t.window = ports.BrokerStreamWindow{}
912927 t.active = 0
913928 }
M internal/adapters/brokerwire/state_test.go +18 -4
@@ -674,8 +674,9 @@ type streamStep struct {
674674 wantErr error
675675 }
676676
677-// TestStreamTrackerOpenRules proves stream identities strictly increase, are
678-// never reused, and that at most MaxStreams streams are concurrent.
677+// TestStreamTrackerOpenRules proves a stream identity is admitted at most once
678+// inside the bounded anti-replay window, that a concurrent lower identity is not
679+// mistaken for a replay, and that at most MaxStreams streams are concurrent.
679680 func TestStreamTrackerOpenRules(t *testing.T) {
680681 scope := testBrokerScope()
681682
@@ -685,12 +686,15 @@ func TestStreamTrackerOpenRules(t *testing.T) {
685686 require.ErrorIs(t, tr.Open(scope, 0), ErrInvalidStream)
686687 })
687688
688- t.Run("strictly increasing and never reused", func(t *testing.T) {
689+ t.Run("each identity consumed at most once", func(t *testing.T) {
689690 tr, err := newStreamTracker(scope)
690691 require.NoError(t, err)
691692 require.NoError(t, tr.Open(scope, 5))
692693 require.ErrorIs(t, tr.Open(scope, 5), ErrStreamIDReused)
693- require.ErrorIs(t, tr.Open(scope, 1), ErrStreamIDReused)
694+ // A concurrent lower identity that was allocated first is admitted, not
695+ // refused as a replay: two racing opens may be scheduled in either
696+ // order.
697+ require.NoError(t, tr.Open(scope, 1))
694698 require.NoError(t, tr.Opened(scope, 5))
695699 require.NoError(t, tr.Open(scope, 6))
696700 if disposition, err := tr.Close(scope, 5); err != nil || disposition != StreamAccepted {
@@ -698,9 +702,19 @@ func TestStreamTrackerOpenRules(t *testing.T) {
698702 }
699703 require.ErrorIs(t, tr.Open(scope, 5), ErrStreamIDReused)
700704 require.ErrorIs(t, tr.Open(scope, 6), ErrStreamIDReused)
705+ require.ErrorIs(t, tr.Open(scope, 1), ErrStreamIDReused)
701706 require.NoError(t, tr.Open(scope, 7))
702707 })
703708
709+ t.Run("identity evicted past the window is stale", func(t *testing.T) {
710+ tr, err := newStreamTracker(scope)
711+ require.NoError(t, err)
712+ require.NoError(t, tr.Open(scope, 1))
713+ require.NoError(t, tr.Open(scope, ports.BrokerStreamWindowSize+1))
714+ require.ErrorIs(t, tr.Open(scope, 1), ErrStreamIDReused,
715+ "an identity that trails the newest admitted one by the window is stale")
716+ })
717+
704718 t.Run("concurrent ceiling", func(t *testing.T) {
705719 tr, err := newStreamTracker(scope)
706720 require.NoError(t, err)
M internal/adapters/config/config.go +0 -29
@@ -37,7 +37,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3737 seenTabsKeys := make(map[string]bool)
3838 seenScrollbackKeys := make(map[string]bool)
3939 seenWebKeys := make(map[string]bool)
40- seenRemoteKeys := make(map[string]bool)
4140
4241 scanner := bufio.NewScanner(r)
4342 lineNo := 0
@@ -65,34 +64,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
6564 }
6665
6766 switch {
68- case key == "remote.attachment-cache":
69- warnings = warnDuplicateKey(warnings, seenRemoteKeys, key, lineNo)
70- on, ok := parseOnOff(value)
71- if !ok {
72- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q", key, value)})
73- continue
74- }
75- cfg.AttachmentCache.Enabled = on
76- case key == "remote.attachment-cache-capacity":
77- warnings = warnDuplicateKey(warnings, seenRemoteKeys, key, lineNo)
78- capacity, err := strconv.Atoi(value)
79- if err != nil || capacity <= 0 {
80- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q", key, value)})
81- continue
82- }
83- cfg.AttachmentCache.Capacity = capacity
84- case key == "remote.attachment-cache-idle-timeout":
85- warnings = warnDuplicateKey(warnings, seenRemoteKeys, key, lineNo)
86- if strings.EqualFold(value, "off") {
87- cfg.AttachmentCache.IdleTimeout = 0
88- continue
89- }
90- timeout, err := time.ParseDuration(value)
91- if err != nil || timeout <= 0 {
92- warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid %s %q", key, value)})
93- continue
94- }
95- cfg.AttachmentCache.IdleTimeout = timeout
9667 case key == "web.listen" || key == "web.origin":
9768 warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo)
9869 // Keep raw values for fail-closed validation at gateway startup.
M internal/adapters/config/config_test.go +0 -1
@@ -359,7 +359,6 @@ func TestParse(t *testing.T) {
359359 tt.want.Palette = domain.Defaults().Palette
360360 }
361361 tt.want.Scrollback = domain.DefaultScrollbackConfig()
362- tt.want.AttachmentCache = domain.DefaultAttachmentCacheConfig()
363362 got, warnings, err := Parse(strings.NewReader(tt.input))
364363 if err != nil {
365364 t.Fatalf("Parse() error = %v", err)
D internal/adapters/config/remote_cache_test.go +0 -46
@@ -1,46 +0,0 @@
1-package config
2-
3-import (
4- "strings"
5- "testing"
6- "time"
7-
8- "github.com/bnema/vev/internal/domain"
9- "github.com/stretchr/testify/require"
10-)
11-
12-func TestParseAttachmentCacheConfig(t *testing.T) {
13- for _, tt := range []struct {
14- name string
15- input string
16- want domain.AttachmentCacheConfig
17- warnings int
18- }{
19- {"default", "", domain.DefaultAttachmentCacheConfig(), 0},
20- {"enabled", "remote.attachment-cache = on", domain.AttachmentCacheConfig{Enabled: true, Capacity: 8}, 0},
21- {"disabled", "remote.attachment-cache = OFF", domain.AttachmentCacheConfig{Enabled: false, Capacity: 8}, 0},
22- {"capacity", "remote.attachment-cache-capacity = 4", domain.AttachmentCacheConfig{Enabled: true, Capacity: 4}, 0},
23- {"idle timeout", "remote.attachment-cache-idle-timeout = 30s", domain.AttachmentCacheConfig{Enabled: true, Capacity: 8, IdleTimeout: 30 * time.Second}, 0},
24- {"idle timeout off", "remote.attachment-cache-idle-timeout = Off", domain.DefaultAttachmentCacheConfig(), 0},
25- {"all keys", "remote.attachment-cache = on\nremote.attachment-cache-capacity = 2\nremote.attachment-cache-idle-timeout = 90s", domain.AttachmentCacheConfig{Enabled: true, Capacity: 2, IdleTimeout: 90 * time.Second}, 0},
26- {"malformed boolean", "remote.attachment-cache = sometimes", domain.DefaultAttachmentCacheConfig(), 1},
27- {"malformed capacity", "remote.attachment-cache-capacity = eight", domain.DefaultAttachmentCacheConfig(), 1},
28- {"zero capacity", "remote.attachment-cache-capacity = 0", domain.DefaultAttachmentCacheConfig(), 1},
29- {"negative capacity", "remote.attachment-cache-capacity = -3", domain.DefaultAttachmentCacheConfig(), 1},
30- {"malformed timeout", "remote.attachment-cache-idle-timeout = forever", domain.DefaultAttachmentCacheConfig(), 1},
31- {"zero timeout", "remote.attachment-cache-idle-timeout = 0s", domain.DefaultAttachmentCacheConfig(), 1},
32- {"negative timeout", "remote.attachment-cache-idle-timeout = -5s", domain.DefaultAttachmentCacheConfig(), 1},
33- {"overflow timeout", "remote.attachment-cache-idle-timeout = 999999999999999h", domain.DefaultAttachmentCacheConfig(), 1},
34- {"duplicate", "remote.attachment-cache-capacity = 2\nremote.attachment-cache-capacity = 3", domain.AttachmentCacheConfig{Enabled: true, Capacity: 3}, 1},
35- {"invalid keeps previous", "remote.attachment-cache-capacity = 4\nremote.attachment-cache-capacity = broken", domain.AttachmentCacheConfig{Enabled: true, Capacity: 4}, 2},
36- {"disabled with values", "remote.attachment-cache = off\nremote.attachment-cache-capacity = 2\nremote.attachment-cache-idle-timeout = 1m", domain.AttachmentCacheConfig{Enabled: false, Capacity: 2, IdleTimeout: time.Minute}, 0},
37- } {
38- t.Run(tt.name, func(t *testing.T) {
39- cfg, warnings, err := Parse(strings.NewReader(tt.input))
40- require.NoError(t, err)
41- require.Equal(t, tt.want, cfg.AttachmentCache)
42- require.Len(t, warnings, tt.warnings)
43- require.Empty(t, cfg.BindingEntries)
44- })
45- }
46-}
A internal/adapters/daemonidentity/identity.go +121 -0
@@ -0,0 +1,121 @@
1+// Package daemonidentity persists the stable authenticated identity of the
2+// per-user daemon. Only daemon-owned startup may create it; readers never do.
3+package daemonidentity
4+
5+import (
6+ "crypto/rand"
7+ "encoding/hex"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "os"
12+ "path/filepath"
13+ "syscall"
14+
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/pkg/safedir"
17+)
18+
19+const (
20+ DirName = "daemon"
21+ FileName = "identity"
22+ encodedSize = 32
23+)
24+
25+var ErrInvalid = errors.New("daemonidentity: invalid identity file")
26+
27+func Path(stateDir string) string { return filepath.Join(stateDir, DirName, FileName) }
28+
29+// Load reads an existing identity without creating any filesystem object.
30+func Load(stateDir string) (ports.BrokerDaemonIdentity, error) {
31+ path := Path(stateDir)
32+ info, err := os.Lstat(path)
33+ if err != nil {
34+ return "", err
35+ }
36+ if !info.Mode().IsRegular() || info.Mode().Perm() != 0o600 {
37+ return "", ErrInvalid
38+ }
39+ st, ok := info.Sys().(*syscall.Stat_t)
40+ if !ok || int(st.Uid) != os.Getuid() {
41+ return "", ErrInvalid
42+ }
43+ f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
44+ if err != nil {
45+ return "", fmt.Errorf("%w: %v", ErrInvalid, err)
46+ }
47+ defer f.Close()
48+ data, err := io.ReadAll(io.LimitReader(f, encodedSize+1))
49+ if err != nil || len(data) != encodedSize {
50+ return "", ErrInvalid
51+ }
52+ decoded := make([]byte, 16)
53+ if _, err := hex.Decode(decoded, data); err != nil {
54+ return "", ErrInvalid
55+ }
56+ id := ports.BrokerDaemonIdentity(string(data))
57+ if err := id.Validate(); err != nil {
58+ return "", ErrInvalid
59+ }
60+ return id, nil
61+}
62+
63+// LoadOrCreate is daemon-owner-only creation. It writes a fresh random value
64+// through an owner-only temporary file and atomically publishes it.
65+func LoadOrCreate(stateDir string) (ports.BrokerDaemonIdentity, error) {
66+ id, err := Load(stateDir)
67+ if err == nil {
68+ return id, nil
69+ }
70+ if !errors.Is(err, os.ErrNotExist) {
71+ return "", err
72+ }
73+ dir := filepath.Dir(Path(stateDir))
74+ if err := safedir.EnsurePrivate(dir); err != nil {
75+ return "", err
76+ }
77+ var raw [16]byte
78+ if _, err := io.ReadFull(rand.Reader, raw[:]); err != nil {
79+ return "", err
80+ }
81+ data := []byte(hex.EncodeToString(raw[:]))
82+ tmp, err := os.CreateTemp(dir, ".identity-")
83+ if err != nil {
84+ return "", err
85+ }
86+ tmpName := tmp.Name()
87+ defer os.Remove(tmpName)
88+ if err = tmp.Chmod(0o600); err == nil {
89+ _, err = tmp.Write(data)
90+ }
91+ if err == nil {
92+ err = tmp.Sync()
93+ }
94+ if closeErr := tmp.Close(); err == nil {
95+ err = closeErr
96+ }
97+ if err != nil {
98+ return "", err
99+ }
100+ if err := os.Rename(tmpName, Path(stateDir)); err != nil {
101+ return "", err
102+ }
103+ d, err := os.Open(dir)
104+ if err == nil {
105+ err = d.Sync()
106+ _ = d.Close()
107+ }
108+ if err != nil {
109+ return "", err
110+ }
111+ return Load(stateDir)
112+}
113+
114+func NewIncarnation() (ports.BrokerDaemonIncarnation, error) {
115+ var id ports.BrokerDaemonIncarnation
116+ _, err := io.ReadFull(rand.Reader, id[:])
117+ if err == nil && id.IsZero() {
118+ return NewIncarnation()
119+ }
120+ return id, err
121+}
A internal/adapters/daemonidentity/identity_test.go +84 -0
@@ -0,0 +1,84 @@
1+package daemonidentity
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestLoadOrCreateStableAndIncarnationFresh(t *testing.T) {
11+ root := t.TempDir()
12+ first, err := LoadOrCreate(root)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ second, err := LoadOrCreate(root)
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if first != second {
21+ t.Fatalf("identity changed: %q != %q", first, second)
22+ }
23+ a, err := NewIncarnation()
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ b, err := NewIncarnation()
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ if a.IsZero() || b.IsZero() || a == b {
32+ t.Fatalf("incarnations not fresh: %x %x", a, b)
33+ }
34+}
35+
36+func TestLoadFailsClosed(t *testing.T) {
37+ for _, tc := range []struct {
38+ name string
39+ prepare func(string) error
40+ }{
41+ {"corrupt", func(root string) error {
42+ if err := os.Mkdir(filepath.Dir(Path(root)), 0o700); err != nil {
43+ return err
44+ }
45+ return os.WriteFile(Path(root), []byte("bad"), 0o600)
46+ }},
47+ {"permissions", func(root string) error {
48+ if err := os.Mkdir(filepath.Dir(Path(root)), 0o700); err != nil {
49+ return err
50+ }
51+ return os.WriteFile(Path(root), []byte("00112233445566778899aabbccddeeff"), 0o644)
52+ }},
53+ {"symlink", func(root string) error {
54+ if err := os.Mkdir(filepath.Dir(Path(root)), 0o700); err != nil {
55+ return err
56+ }
57+ target := filepath.Join(root, "target")
58+ if err := os.WriteFile(target, []byte("00112233445566778899aabbccddeeff"), 0o600); err != nil {
59+ return err
60+ }
61+ return os.Symlink(target, Path(root))
62+ }},
63+ } {
64+ t.Run(tc.name, func(t *testing.T) {
65+ root := t.TempDir()
66+ if err := tc.prepare(root); err != nil {
67+ t.Fatal(err)
68+ }
69+ if _, err := Load(root); !errors.Is(err, ErrInvalid) {
70+ t.Fatalf("error=%v, want ErrInvalid", err)
71+ }
72+ })
73+ }
74+}
75+
76+func TestLoadAbsentDoesNotCreate(t *testing.T) {
77+ root := filepath.Join(t.TempDir(), "absent")
78+ if _, err := Load(root); !errors.Is(err, os.ErrNotExist) {
79+ t.Fatalf("error=%v", err)
80+ }
81+ if _, err := os.Stat(root); !errors.Is(err, os.ErrNotExist) {
82+ t.Fatalf("read created state: %v", err)
83+ }
84+}
M internal/adapters/daemonmux/admission_conversion_test.go +1 -0
@@ -27,6 +27,7 @@ func TestOpenMessageCarriesAttachmentAdmission(t *testing.T) {
2727 Connection: ports.BrokerConnectionID{1},
2828 Stream: ports.BrokerStreamID(1),
2929 Policy: logicalTestPolicy(),
30+ StartMode: ports.BrokerDaemonStartIfNeeded,
3031 }
3132 target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}
3233
A internal/adapters/daemonmux/admission_test.go +233 -0
@@ -0,0 +1,233 @@
1+package daemonmux
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol"
13+)
14+
15+// attachmentRequest builds one valid local attachment Open under policy for the
16+// supplied admission variant, name, target, and environment, so a listener test
17+// exercises the exact metadata a real attachment carries.
18+func attachmentRequest(stream int, policy ports.BrokerPolicy, admission ports.BrokerStreamAdmission, name string, target protocol.ExactSessionTarget, env []string) ports.BrokerOpenStreamRequest {
19+ return ports.BrokerOpenStreamRequest{
20+ Epoch: 1,
21+ Purpose: ports.BrokerStreamAttachment,
22+ Admission: admission,
23+ Name: name,
24+ Local: true,
25+ Connection: ports.BrokerConnectionID{1},
26+ Stream: ports.BrokerStreamID(stream),
27+ Target: target,
28+ Env: append([]string(nil), env...),
29+ Policy: policy,
30+ StartMode: ports.BrokerDaemonStartIfNeeded,
31+ }
32+}
33+
34+// admittedConnection opens one request over the harness while a concurrent
35+// Accept confirms it, returning the accepted typed daemon-side connection. A
36+// listener confirms Opened only as Accept delivers the stream, so the two must
37+// run together.
38+func admittedConnection(t *testing.T, h *listenerHarness, request ports.BrokerOpenStreamRequest) *ListenerConnection {
39+ t.Helper()
40+ accepted := h.acceptAsync()
41+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
42+ defer cancel()
43+ _, err := h.connector.Open(ctx, request)
44+ require.NoError(t, err)
45+ return h.awaitAccept(accepted)
46+}
47+
48+// TestListenerStampsProvisionedOrigin proves the accepted connection's admission
49+// comes from the provisioned binding, not the peer's Open: a listener
50+// provisioned as local stamps Origin=Local with the accepted policy, and every
51+// declared field of the Open is carried verbatim.
52+func TestListenerStampsProvisionedOrigin(t *testing.T) {
53+ policy := logicalTestPolicy()
54+ h := newListenerHarness(t, listenerIdleBudget, MaxAcceptQueue)
55+
56+ // A local control stream carries no attachment state.
57+ connection := admittedConnection(t, h, controlRequest(1))
58+ admission, ok := connection.SessionAdmission()
59+ require.True(t, ok)
60+ require.Equal(t, ports.SessionOriginLocal, admission.Origin, "the provisioned locality is stamped")
61+ require.Equal(t, policy, admission.Policy, "the accepted policy is the provisioned one")
62+ require.Equal(t, ports.BrokerStreamControl, admission.Purpose)
63+ require.Zero(t, admission.Admission)
64+ require.Empty(t, admission.Env)
65+ require.NoError(t, admission.Validate())
66+
67+ // An attachment stream carries its closed variant, validated name, exact
68+ // target, and bounded environment.
69+ target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{7}, SessionName: "work"}
70+ attach := admittedConnection(t, h, attachmentRequest(2, policy, ports.BrokerAdmissionExact, "", target, []string{"TERM=xterm-256color"}))
71+ attachAdmission, ok := attach.SessionAdmission()
72+ require.True(t, ok)
73+ require.Equal(t, ports.SessionOriginLocal, attachAdmission.Origin)
74+ require.Equal(t, ports.BrokerStreamAttachment, attachAdmission.Purpose)
75+ require.Equal(t, ports.BrokerAdmissionExact, attachAdmission.Admission)
76+ require.Equal(t, target, attachAdmission.Target)
77+ require.Equal(t, []string{"TERM=xterm-256color"}, attachAdmission.Env)
78+ require.NoError(t, attachAdmission.Validate())
79+}
80+
81+// TestListenerRefusesLiarLocalOrigin proves the accepted origin is enforced, not
82+// echoed: a listener provisioned for a remote carriage refuses a local Open (and
83+// one provisioned local refuses a remote Open) exactly on its own stream, so a
84+// peer can never stamp a provenance the accepting side did not provision.
85+func TestListenerRefusesLiarLocalOrigin(t *testing.T) {
86+ policy := logicalTestPolicy()
87+
88+ t.Run("remote carriage refuses a local Open", func(t *testing.T) {
89+ brokerPump, daemonPump, start := newPairedPumpsUnstarted(t, DefaultMuxCeilings())
90+ clock := newListenerClock(time.Now())
91+ listener, err := newListener(daemonPump, listenerIdleBudget, MaxAcceptQueue, clock, remoteAcceptance(policy))
92+ require.NoError(t, err)
93+ t.Cleanup(func() { _ = listener.Close() })
94+ start()
95+ connector := mustConnector(t, brokerPump)
96+
97+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
98+ defer cancel()
99+ _, err = connector.Open(ctx, controlRequest(1))
100+ require.Error(t, err, "a local Open is refused by a remote carriage")
101+
102+ refused := requireStreamState(t, daemonPump, 1, StreamTerminal)
103+ require.Equal(t, domain.RemoteFailureTransport, refused.FailureKind)
104+ var detail ports.BrokerError
105+ require.ErrorAs(t, refused.Err, &detail)
106+ require.Equal(t, invalidAdmissionText, detail.Text)
107+
108+ // Nothing was queued for the daemon: a refused Open is never delivered.
109+ listener.mu.Lock()
110+ queued := len(listener.queue)
111+ listener.mu.Unlock()
112+ require.Zero(t, queued, "a refused Open is never queued")
113+ })
114+
115+ t.Run("local carriage refuses a remote Open", func(t *testing.T) {
116+ h := newListenerHarness(t, listenerIdleBudget, MaxAcceptQueue)
117+ remote := controlRequest(1)
118+ remote.Local = false
119+ remote.Endpoint = "dev@host:22"
120+ remote.Registration = testRegistration()
121+
122+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
123+ defer cancel()
124+ _, err := h.connector.Open(ctx, remote)
125+ require.Error(t, err, "a remote Open is refused by a local carriage")
126+
127+ refused := requireStreamState(t, h.daemonPump, 1, StreamTerminal)
128+ var detail ports.BrokerError
129+ require.ErrorAs(t, refused.Err, &detail)
130+ require.Equal(t, invalidAdmissionText, detail.Text)
131+ })
132+}
133+
134+// TestListenerAdmissionIsolationAndDeepCopy proves every accepted connection
135+// owns an independent admission: mutating one connection's returned copy never
136+// changes a sibling's, and concurrent streams each receive exactly the metadata
137+// their own Open declared.
138+func TestListenerAdmissionIsolationAndDeepCopy(t *testing.T) {
139+ policy := logicalTestPolicy()
140+ h := newListenerHarness(t, listenerIdleBudget, MaxAcceptQueue)
141+
142+ first := admittedConnection(t, h, attachmentRequest(1, policy, ports.BrokerAdmissionCreateNamed, "alpha", protocol.ExactSessionTarget{}, []string{"TERM=one"}))
143+
144+ // The provider hands out a defensive copy: mutating it must not alter what
145+ // the connection (or a later read) reports.
146+ mutated, ok := first.SessionAdmission()
147+ require.True(t, ok)
148+ mutated.Env[0] = "TERM=mutated"
149+ mutated.Name = "mutated"
150+ mutated.Origin = ports.SessionOriginRemote
151+ reread, ok := first.SessionAdmission()
152+ require.True(t, ok)
153+ require.Equal(t, "alpha", reread.Name)
154+ require.Equal(t, ports.SessionOriginLocal, reread.Origin)
155+ require.Equal(t, []string{"TERM=one"}, reread.Env)
156+
157+ // Two reads never share the same backing slice.
158+ left, _ := first.SessionAdmission()
159+ right, _ := first.SessionAdmission()
160+ left.Env[0] = "TERM=left"
161+ require.Equal(t, "TERM=one", right.Env[0])
162+
163+ // A sibling stream carries only its own declaration; the first's mutation
164+ // never leaks into it.
165+ second := admittedConnection(t, h, attachmentRequest(2, policy, ports.BrokerAdmissionCreateEphemeral, "", protocol.ExactSessionTarget{}, nil))
166+ secondAdmission, ok := second.SessionAdmission()
167+ require.True(t, ok)
168+ require.Equal(t, ports.BrokerAdmissionCreateEphemeral, secondAdmission.Admission)
169+ require.Empty(t, secondAdmission.Name)
170+ require.Empty(t, secondAdmission.Env)
171+}
172+
173+// TestListenerConcurrentAdmissionsKeepDistinctMetadata proves the listener's
174+// per-stream admission stamp is per stream under concurrency: every admitted
175+// attachment receives exactly the name, target, and environment its own Open
176+// declared, and no stream sees a sibling's.
177+func TestListenerConcurrentAdmissionsKeepDistinctMetadata(t *testing.T) {
178+ policy := logicalTestPolicy()
179+ h := newListenerHarness(t, listenerIdleBudget, 64)
180+
181+ const streams = 24
182+ type expectation struct {
183+ admission ports.SessionAdmission
184+ }
185+ want := make(map[ports.BrokerStreamID]expectation, streams)
186+ opened := make(chan *ListenerConnection, streams)
187+ openErrs := make(chan error, streams)
188+ for i := 0; i < streams; i++ {
189+ stream := ports.BrokerStreamID(i + 1)
190+ name := ""
191+ if i%2 == 0 {
192+ name = []string{"alpha", "beta", "gamma"}[i%3]
193+ }
194+ env := []string{[]string{"TERM=first", "TERM=second", "TERM=third"}[i%3]}
195+ admission := ports.BrokerAdmissionCreateNamed
196+ if name == "" {
197+ admission = ports.BrokerAdmissionCreateEphemeral
198+ }
199+ want[stream] = expectation{admission: ports.SessionAdmission{
200+ Origin: ports.SessionOriginLocal, Policy: policy, Purpose: ports.BrokerStreamAttachment,
201+ Admission: admission, Name: name, Env: env,
202+ }}
203+ request := attachmentRequest(i+1, policy, admission, name, protocol.ExactSessionTarget{}, env)
204+ go func(request ports.BrokerOpenStreamRequest) {
205+ // The Accept that confirms this stream must be in flight before the
206+ // Open, because a listener sends Opened only as Accept delivers.
207+ accepted := h.acceptAsync()
208+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
209+ defer cancel()
210+ _, err := h.connector.Open(ctx, request)
211+ if err != nil {
212+ openErrs <- err
213+ return
214+ }
215+ opened <- h.awaitAccept(accepted)
216+ }(request)
217+ }
218+
219+ for i := 0; i < streams; i++ {
220+ select {
221+ case err := <-openErrs:
222+ require.NoError(t, err)
223+ case connection := <-opened:
224+ admission, ok := connection.SessionAdmission()
225+ require.True(t, ok)
226+ expected, tracked := want[connection.Ref().Client]
227+ require.True(t, tracked)
228+ require.Equal(t, expected.admission, admission, "stream %d", connection.Ref().Client)
229+ case <-time.After(p3cTestDeadline):
230+ t.Fatal("concurrent admissions did not all complete")
231+ }
232+ }
233+}
M internal/adapters/daemonmux/binding.go +90 -58
@@ -1,91 +1,123 @@
11 package daemonmux
22
3-// Server binding (P3.2a).
4-//
5-// ServerBinding is the daemon's validated, immutable authority for one
6-// accepted daemonmux physical connection: the stable authenticated daemon
7-// identity used for pooling, this process incarnation used to detect a
8-// restart behind that identity, and the exact connection policy the daemon
9-// requires. It is constructed once, validates its own invariants, exposes
10-// only read accessors, and is never mutated, so a later change to a caller's
11-// local value cannot weaken what the server handshake already enforced.
12-//
13-// The server handshake compares a client's requested policy against this
14-// binding rather than echoing the request back: an accepted response carries
15-// the binding's identity, incarnation, and policy. Identity is stable across
16-// restarts; the incarnation is a freshly sampled nonzero 16-byte value.
17-//
18-// Authentication premise: the binding is only as trustworthy as the carrier
19-// that carried the handshake. The carriage must already be authenticated
20-// (for example an established QUIC channel with an exact peer pin) before a
21-// connection reaches this handshake, and the expected endpoint binding is
22-// authoritative. This package never authenticates a carrier and never treats
23-// an in-band identity claim as proof.
24-
253 import (
264 "errors"
275
286 "github.com/bnema/vev/internal/ports"
297 )
308
31-// ErrInvalidBinding reports a daemon-side physical binding that is not
32-// authoritative: an invalid or empty authenticated daemon identity, a zero
33-// daemon incarnation, or an invalid connection policy. NewServerBinding
34-// refuses it once, at construction, so the handshake never re-validates an
35-// unusable binding.
36-var ErrInvalidBinding = errors.New("daemonmux: invalid server binding")
9+// ErrInvalidBinding reports an invalid daemon authority. An authority must
10+// contain a valid identity, a non-zero incarnation, and a non-empty closed set
11+// of distinct, valid provisioned admissions.
12+var ErrInvalidBinding = errors.New("daemonmux: invalid server bindings")
13+
14+// ServerPolicyAdmission is one provisioned carriage shape the daemon accepts:
15+// the exact connection policy for that shape and the locality it serves. A
16+// daemon serves its own local carriage and each provisioned remote transport, so
17+// one binding legitimately carries several entries; the origin makes the
18+// accepted locality explicit rather than inferring it from the peer's Open.
19+//
20+// The origin is per policy, not globally unique: two remote transport policies
21+// (for example quic and stdio) share the one remote locality, so admission
22+// resolves origin from the accepted policy entry rather than a global origin
23+// table.
24+type ServerPolicyAdmission struct {
25+ Policy ports.BrokerPolicy
26+ Origin ports.SessionConnectionOrigin
27+}
28+
29+// Validate enforces the closed provisioned-entry contract: a valid exact policy
30+// and a known locality. A missing or invalid origin is refused, so an accepted
31+// entry can never inherit an implicit locality.
32+func (e ServerPolicyAdmission) Validate() error {
33+ if err := e.Policy.Validate(); err != nil {
34+ return err
35+ }
36+ return e.Origin.Validate()
37+}
3738
38-// ServerBinding is the daemon's validated, immutable physical binding for one
39-// accepted daemonmux connection. Its fields are unexported and reachable only
40-// through the Identity, Incarnation, and Policy accessors, so a ServerBinding
41-// value is immutable once constructed and safe to copy and share.
39+// ServerBinding is an immutable daemon authority. The entry slice is cloned at
40+// construction and is never exposed, making the admitted set safe to share
41+// between concurrent handshakes.
4242 type ServerBinding struct {
4343 identity ports.BrokerDaemonIdentity
4444 incarnation ports.BrokerDaemonIncarnation
45- policy ports.BrokerPolicy
45+ entries []ServerPolicyAdmission
4646 }
4747
48-// NewServerBinding validates and returns the daemon's immutable physical
49-// binding. It refuses an invalid authenticated identity, a zero incarnation,
50-// or an invalid policy with ErrInvalidBinding; the returned binding either
51-// validates completely or is never handed out.
52-func NewServerBinding(identity ports.BrokerDaemonIdentity, incarnation ports.BrokerDaemonIncarnation, policy ports.BrokerPolicy) (ServerBinding, error) {
53- binding := ServerBinding{identity: identity, incarnation: incarnation, policy: policy}
48+// NewServerBindings constructs a closed admission authority from provisioned
49+// entries. An empty set, a duplicate policy, an invalid policy, and an unknown
50+// origin are rejected. Entries are cloned, so a later change to the caller's
51+// slice cannot weaken what the handshake already enforced.
52+func NewServerBindings(identity ports.BrokerDaemonIdentity, incarnation ports.BrokerDaemonIncarnation, entries []ServerPolicyAdmission) (ServerBinding, error) {
53+ binding := ServerBinding{identity: identity, incarnation: incarnation, entries: append([]ServerPolicyAdmission(nil), entries...)}
5454 if err := binding.Validate(); err != nil {
5555 return ServerBinding{}, err
5656 }
5757 return binding, nil
5858 }
5959
60-// Identity returns the stable authenticated daemon identity the binding
61-// advertises and pools on.
62-func (b ServerBinding) Identity() ports.BrokerDaemonIdentity { return b.identity }
60+// NewServerBinding is retained as a source-compatible shorthand for fixtures
61+// that provision exactly one local policy. It is never used by production
62+// composition, which builds the daemon's whole closed set with NewServerBindings.
63+func NewServerBinding(identity ports.BrokerDaemonIdentity, incarnation ports.BrokerDaemonIncarnation, policy ports.BrokerPolicy) (ServerBinding, error) {
64+ return NewServerBindings(identity, incarnation, []ServerPolicyAdmission{{Policy: policy, Origin: ports.SessionOriginLocal}})
65+}
6366
64-// Incarnation returns the nonzero process incarnation the binding advertises
65-// so a broker can detect a restart behind the stable identity.
67+func (b ServerBinding) Identity() ports.BrokerDaemonIdentity { return b.identity }
6668 func (b ServerBinding) Incarnation() ports.BrokerDaemonIncarnation { return b.incarnation }
6769
68-// Policy returns the exact connection policy the binding requires.
69-func (b ServerBinding) Policy() ports.BrokerPolicy { return b.policy }
70+// Policy returns the sole entry's policy for legacy single-policy callers. It
71+// returns the zero value for a multi-entry authority; admission never uses it.
72+func (b ServerBinding) Policy() ports.BrokerPolicy {
73+ if len(b.entries) == 1 {
74+ return b.entries[0].Policy
75+ }
76+ return ports.BrokerPolicy{}
77+}
7078
71-// Validate reports whether the binding is authoritative: a valid authenticated
72-// identity, a nonzero incarnation, and a valid policy.
73-func (b ServerBinding) Validate() error {
74- if err := b.identity.Validate(); err != nil {
75- return ErrInvalidBinding
79+// Origin returns the sole entry's locality for legacy single-policy callers. It
80+// returns the unknown origin for a multi-entry authority.
81+func (b ServerBinding) Origin() ports.SessionConnectionOrigin {
82+ if len(b.entries) == 1 {
83+ return b.entries[0].Origin
7684 }
77- if err := b.incarnation.Validate(); err != nil {
85+ return ports.SessionOriginUnknown
86+}
87+
88+func (b ServerBinding) Validate() error {
89+ if b.identity.Validate() != nil || b.incarnation.Validate() != nil || len(b.entries) == 0 {
7890 return ErrInvalidBinding
7991 }
80- if err := b.policy.Validate(); err != nil {
81- return ErrInvalidBinding
92+ for i, entry := range b.entries {
93+ if entry.Validate() != nil {
94+ return ErrInvalidBinding
95+ }
96+ for j := 0; j < i; j++ {
97+ // Duplicate policy - and therefore a duplicated entry - is refused:
98+ // one policy is one carriage shape, so a second entry naming the
99+ // same policy would make the accepted locality and policy ambiguous.
100+ if entry.Policy.Compatible(b.entries[j].Policy) {
101+ return ErrInvalidBinding
102+ }
103+ }
82104 }
83105 return nil
84106 }
85107
86-// Accepts reports whether the requested policy exactly equals the binding's
87-// policy. Pooling requires exact equality across every field, so a request
88-// that differs in any field is refused rather than merged or inherited.
108+// Accepted returns the exact provisioned entry matching policy, carrying that
109+// entry's policy and origin. No merging, inheritance, echo, or fallback is
110+// performed: a request differing in one field is refused.
111+func (b ServerBinding) Accepted(policy ports.BrokerPolicy) (ServerPolicyAdmission, bool) {
112+ for _, provisioned := range b.entries {
113+ if provisioned.Policy.Compatible(policy) {
114+ return provisioned, true
115+ }
116+ }
117+ return ServerPolicyAdmission{}, false
118+}
119+
89120 func (b ServerBinding) Accepts(policy ports.BrokerPolicy) bool {
90- return b.policy.Compatible(policy)
121+ _, ok := b.Accepted(policy)
122+ return ok
91123 }
A internal/adapters/daemonmux/binding_set_test.go +202 -0
@@ -0,0 +1,202 @@
1+package daemonmux
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestServerBindingsClosedSetCloneAndDuplicates(t *testing.T) {
12+ first := testPolicy()
13+ second := first
14+ second.Transport = "ssh-stdio"
15+ entries := []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second)}
16+ binding, err := NewServerBindings(testIdentity(), testIncarnation(), entries)
17+ require.NoError(t, err)
18+ entries[0] = localAcceptance(second)
19+ require.True(t, binding.Accepts(first), "constructor must clone its authority")
20+ require.True(t, binding.Accepts(second))
21+ variation := second
22+ variation.Trust += "-other"
23+ require.False(t, binding.Accepts(variation))
24+ _, err = NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(first)})
25+ require.ErrorIs(t, err, ErrInvalidBinding)
26+ _, err = NewServerBindings(testIdentity(), testIncarnation(), nil)
27+ require.ErrorIs(t, err, ErrInvalidBinding)
28+}
29+
30+func TestServerBindingsConcurrentAdmission(t *testing.T) {
31+ policy := testPolicy()
32+ binding, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(policy)})
33+ require.NoError(t, err)
34+ var wg sync.WaitGroup
35+ for range 100 {
36+ wg.Add(1)
37+ go func() { defer wg.Done(); require.True(t, binding.Accepts(policy)) }()
38+ }
39+ wg.Wait()
40+}
41+
42+// TestServerBindingsSinglePolicyShorthandMatchesSetConstructor proves the
43+// fixtures-only shorthand is exactly the one-member closed set: same authority,
44+// same decisions, same exposure through Policy().
45+func TestServerBindingsSinglePolicyShorthandMatchesSetConstructor(t *testing.T) {
46+ shorthand, err := NewServerBinding(testIdentity(), testIncarnation(), testPolicy())
47+ require.NoError(t, err)
48+ set, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(testPolicy())})
49+ require.NoError(t, err)
50+
51+ require.Equal(t, set.Identity(), shorthand.Identity())
52+ require.Equal(t, set.Incarnation(), shorthand.Incarnation())
53+ require.Equal(t, set.Policy(), shorthand.Policy())
54+ require.NoError(t, shorthand.Validate())
55+ for _, candidate := range []ports.BrokerPolicy{testPolicy(), func() ports.BrokerPolicy { p := testPolicy(); p.Transport = "other"; return p }()} {
56+ require.Equal(t, set.Accepts(candidate), shorthand.Accepts(candidate))
57+ }
58+}
59+
60+// TestServerBindingsAcceptedReturnsProvisionedMember proves admission answers
61+// with the member the configuration provisioned and never with the request: no
62+// merging, inheritance, echo, or fallback can widen the configured authority.
63+// The answered entry carries the provisioned locality alongside its policy.
64+func TestServerBindingsAcceptedReturnsProvisionedMember(t *testing.T) {
65+ first := testPolicy()
66+ second := first
67+ second.Transport = "ssh-stdio"
68+ second.Trust = "pinned"
69+ binding, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second)})
70+ require.NoError(t, err)
71+
72+ accepted, ok := binding.Accepted(second)
73+ require.True(t, ok)
74+ require.Equal(t, remoteAcceptance(second), accepted, "admission answers the provisioned member, not the request")
75+ require.Equal(t, ports.SessionOriginRemote, accepted.Origin)
76+
77+ local, ok := binding.Accepted(first)
78+ require.True(t, ok)
79+ require.Equal(t, localAcceptance(first), local)
80+ require.Equal(t, ports.SessionOriginLocal, local.Origin)
81+
82+ // A request differing in exactly one field is refused outright: it can
83+ // never be answered by a member it was not provisioned as.
84+ variations := []func(*ports.BrokerPolicy){
85+ func(p *ports.BrokerPolicy) { p.ProtocolVersion++ },
86+ func(p *ports.BrokerPolicy) { p.CatalogSchemaVersion++ },
87+ func(p *ports.BrokerPolicy) { p.Transport = "other" },
88+ func(p *ports.BrokerPolicy) { p.Trust = "other" },
89+ func(p *ports.BrokerPolicy) { p.Isolation = "other" },
90+ }
91+ for _, mutate := range variations {
92+ requested := second
93+ mutate(&requested)
94+ accepted, ok := binding.Accepted(requested)
95+ require.False(t, ok, "a one-field variation must not be admitted")
96+ require.Equal(t, ServerPolicyAdmission{}, accepted)
97+ }
98+}
99+
100+// TestServerBindingsPolicyExposesNoMultiMemberAuthority proves Policy() answers
101+// only a genuine single-member authority: a multi-member binding exposes the
102+// zero value rather than one of its members, so a caller can never mistake an
103+// arbitrary member for the authority of the whole daemon.
104+func TestServerBindingsPolicyExposesNoMultiMemberAuthority(t *testing.T) {
105+ first := testPolicy()
106+ second := first
107+ second.Transport = "ssh-stdio"
108+ binding, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second)})
109+ require.NoError(t, err)
110+ require.Equal(t, ports.BrokerPolicy{}, binding.Policy())
111+ require.True(t, binding.Accepts(first))
112+ require.True(t, binding.Accepts(second))
113+}
114+
115+// TestServerBindingsRefusesInvalidMemberAmongValid proves validation is
116+// all-or-nothing: one invalid member refuses the whole closed set instead of
117+// publishing a partially valid authority. An entry with an unknown or invalid
118+// locality is refused the same way, so an accepted member always names a
119+// provisioned origin.
120+func TestServerBindingsRefusesInvalidMemberAmongValid(t *testing.T) {
121+ invalid := testPolicy()
122+ invalid.ProtocolVersion = 0
123+
124+ tests := []struct {
125+ name string
126+ entries []ServerPolicyAdmission
127+ }{
128+ {name: "invalid member first", entries: []ServerPolicyAdmission{localAcceptance(invalid), remoteAcceptance(testPolicy())}},
129+ {name: "invalid member last", entries: []ServerPolicyAdmission{localAcceptance(testPolicy()), remoteAcceptance(invalid)}},
130+ {name: "zero policy member", entries: []ServerPolicyAdmission{localAcceptance(testPolicy()), remoteAcceptance(ports.BrokerPolicy{})}},
131+ {name: "unknown origin", entries: []ServerPolicyAdmission{{Policy: testPolicy(), Origin: ports.SessionOriginUnknown}}},
132+ {name: "invalid origin", entries: []ServerPolicyAdmission{{Policy: testPolicy(), Origin: ports.SessionConnectionOrigin(9)}}},
133+ }
134+ for _, tt := range tests {
135+ t.Run(tt.name, func(t *testing.T) {
136+ _, err := NewServerBindings(testIdentity(), testIncarnation(), tt.entries)
137+ require.ErrorIs(t, err, ErrInvalidBinding)
138+ })
139+ }
140+}
141+
142+// TestServerBindingsRefusesInvalidAuthority proves an empty identity and a zero
143+// incarnation are refused regardless of how many valid policies follow.
144+func TestServerBindingsRefusesInvalidAuthority(t *testing.T) {
145+ tests := []struct {
146+ name string
147+ identity ports.BrokerDaemonIdentity
148+ incarnation ports.BrokerDaemonIncarnation
149+ }{
150+ {name: "empty identity", identity: "", incarnation: testIncarnation()},
151+ {name: "zero incarnation", identity: testIdentity(), incarnation: ports.BrokerDaemonIncarnation{}},
152+ }
153+ for _, tt := range tests {
154+ t.Run(tt.name, func(t *testing.T) {
155+ _, err := NewServerBindings(tt.identity, tt.incarnation, []ServerPolicyAdmission{localAcceptance(testPolicy())})
156+ require.ErrorIs(t, err, ErrInvalidBinding)
157+ })
158+ }
159+}
160+
161+// TestServerBindingsOriginShorthandMatchesSetConstructor proves Origin()
162+// answers only a genuine single-member authority, while a multi-member binding
163+// exposes the unknown origin rather than an arbitrary member's locality.
164+func TestServerBindingsOriginShorthandMatchesSetConstructor(t *testing.T) {
165+ shorthand, err := NewServerBinding(testIdentity(), testIncarnation(), testPolicy())
166+ require.NoError(t, err)
167+ require.Equal(t, ports.SessionOriginLocal, shorthand.Origin())
168+
169+ first := testPolicy()
170+ second := first
171+ second.Transport = "ssh-stdio"
172+ multi, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second)})
173+ require.NoError(t, err)
174+ require.Equal(t, ports.SessionOriginUnknown, multi.Origin())
175+}
176+
177+// TestServerBindingsConcurrentMultiMemberAdmission proves a shared closed set is
178+// safe to admit against from many handshakes at once, resolving each concurrent
179+// request to its own provisioned member.
180+func TestServerBindingsConcurrentMultiMemberAdmission(t *testing.T) {
181+ first := testPolicy()
182+ second := first
183+ second.Transport = "ssh-stdio"
184+ third := first
185+ third.Isolation = "system"
186+ binding, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second), remoteAcceptance(third)})
187+ require.NoError(t, err)
188+
189+ candidates := []ServerPolicyAdmission{localAcceptance(first), remoteAcceptance(second), remoteAcceptance(third)}
190+ var wg sync.WaitGroup
191+ for i := range 150 {
192+ wg.Add(1)
193+ go func(index int) {
194+ defer wg.Done()
195+ want := candidates[index%len(candidates)]
196+ accepted, ok := binding.Accepted(want.Policy)
197+ require.True(t, ok)
198+ require.Equal(t, want, accepted)
199+ }(i)
200+ }
201+ wg.Wait()
202+}
M internal/adapters/daemonmux/codec.go +15 -1
@@ -273,7 +273,7 @@ func openToWire(m Open) (*wire.MuxOpen, error) {
273273 Epoch: m.Ref.Epoch, Purpose: m.Purpose, Admission: m.Admission, Name: m.Name, Local: m.Local,
274274 Connection: m.Ref.Connection, Stream: m.Ref.Client,
275275 Endpoint: m.Endpoint, Registration: m.Registration,
276- Target: m.Target, Env: m.Env, Policy: m.Policy,
276+ Target: m.Target, Env: m.Env, Policy: m.Policy, StartMode: m.StartMode,
277277 }
278278 if err := request.Validate(); err != nil {
279279 // Distinguish bound refusals from semantic refusals.
@@ -294,11 +294,19 @@ func openToWire(m Open) (*wire.MuxOpen, error) {
294294 if err != nil {
295295 return nil, err
296296 }
297+ startMode, err := startModeToWire(m.StartMode)
298+ if err != nil {
299+ return nil, err
300+ }
297301 out := &wire.MuxOpen{
298302 Ref: refToWire(m.Ref),
299303 Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint,
300304 Env: append([]string(nil), m.Env...),
301305 Policy: policyToWire(m.Policy),
306+ // The daemon-start authorization always travels, including for
307+ // observation and control streams, so the daemon-side transport can
308+ // never infer a spawn from an absent field.
309+ StartMode: startMode,
302310 }
303311 if !m.Local {
304312 out.Registration = registrationToWire(m.Registration)
@@ -336,6 +344,10 @@ func openFromWire(message *wire.MuxOpen) (Open, error) {
336344 if purpose != ports.BrokerStreamAttachment && (admission != 0 || name != "") {
337345 return Open{}, ErrInvalidMessage
338346 }
347+ startMode, err := startModeFromWire(message.GetStartMode())
348+ if err != nil {
349+ return Open{}, ErrInvalidMessage
350+ }
339351 local := message.GetLocal()
340352 var registration domain.RemoteRegistration
341353 if !local {
@@ -374,12 +386,14 @@ func openFromWire(message *wire.MuxOpen) (Open, error) {
374386 candidate := Open{
375387 Ref: ref, Purpose: purpose, Admission: admission, Name: name, Local: local, Endpoint: endpoint,
376388 Registration: registration, Target: target, Env: env, Policy: policy,
389+ StartMode: startMode,
377390 }
378391 request := ports.BrokerOpenStreamRequest{
379392 Epoch: candidate.Ref.Epoch, Purpose: candidate.Purpose, Admission: candidate.Admission, Name: candidate.Name, Local: candidate.Local,
380393 Connection: candidate.Ref.Connection, Stream: candidate.Ref.Client,
381394 Endpoint: candidate.Endpoint, Registration: candidate.Registration,
382395 Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,
396+ StartMode: candidate.StartMode,
383397 }
384398 if err := request.Validate(); err != nil {
385399 return Open{}, ErrInvalidMessage
M internal/adapters/daemonmux/codec_test.go +76 -4
@@ -43,6 +43,18 @@ func testPolicy() ports.BrokerPolicy {
4343 }
4444 }
4545
46+// localAcceptance provisions one local carriage entry under policy, matching
47+// the local Open requests the shared fixtures send.
48+func localAcceptance(policy ports.BrokerPolicy) ServerPolicyAdmission {
49+ return ServerPolicyAdmission{Policy: policy, Origin: ports.SessionOriginLocal}
50+}
51+
52+// remoteAcceptance provisions one remote carriage entry under policy, matching
53+// a remote Open carrying a validated registration.
54+func remoteAcceptance(policy ports.BrokerPolicy) ServerPolicyAdmission {
55+ return ServerPolicyAdmission{Policy: policy, Origin: ports.SessionOriginRemote}
56+}
57+
4658 func testTarget() protocol.ExactSessionTarget {
4759 return protocol.ExactSessionTarget{
4860 LifecycleID: domain.SessionLifecycleID{1, 2, 3},
@@ -97,6 +109,7 @@ func testOpen() Open {
97109 Ref: testRef(5), Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Local: false,
98110 Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(),
99111 Env: []string{"TERM=xterm-256color"}, Policy: testPolicy(),
112+ StartMode: ports.BrokerDaemonStartIfNeeded,
100113 }
101114 }
102115
@@ -371,7 +384,7 @@ func TestMuxValidationNegatives(t *testing.T) {
371384 _, err = EncodeServer(Opened{Ref: zeroClient}, testEnvelopeCeiling, testChunkCeiling)
372385 require.ErrorIs(t, err, ErrInvalidMessage)
373386
374- _, err = EncodeClient(Open{Ref: zeroClient, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
387+ _, err = EncodeClient(Open{Ref: zeroClient, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}, testEnvelopeCeiling, testChunkCeiling)
375388 require.ErrorIs(t, err, ErrInvalidMessage)
376389 })
377390 t.Run("open taxonomy and shape", func(t *testing.T) {
@@ -412,7 +425,7 @@ func TestMuxValidationNegatives(t *testing.T) {
412425 require.ErrorIs(t, err, ErrInvalidMessage)
413426
414427 // A local control open with no attachment state is legal.
415- valid := Open{Ref: testRef(5), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}
428+ valid := Open{Ref: testRef(5), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}
416429 raw, err := EncodeClient(valid, testEnvelopeCeiling, testChunkCeiling)
417430 require.NoError(t, err)
418431 decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
@@ -694,7 +707,7 @@ func TestMuxAdmissionVariants(t *testing.T) {
694707 require.Equal(t, ephemeral, roundTrip(t, ephemeral))
695708 })
696709 t.Run("control never carries admission", func(t *testing.T) {
697- control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}
710+ control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}
698711 require.Equal(t, control, roundTrip(t, control))
699712 mutated := mustEncodeClient(t, control)
700713 envelope := &wire.MuxClientEnvelope{}
@@ -731,13 +744,72 @@ func TestMuxAdmissionVariants(t *testing.T) {
731744 })
732745 }
733746
747+// TestMuxStartModeRoundTrips proves the daemon-start authorization travels the
748+// mux wire losslessly for every stream purpose, that the daemon-side decoder
749+// never widens an existing-only authorization, and that the zero and unknown
750+// wire codes are refused rather than defaulted.
751+func TestMuxStartModeRoundTrips(t *testing.T) {
752+ for _, tc := range []struct {
753+ name string
754+ mode ports.BrokerDaemonStartMode
755+ purpose ports.BrokerStreamPurpose
756+ admission ports.BrokerStreamAdmission
757+ }{
758+ {"existing only control", ports.BrokerDaemonExistingOnly, ports.BrokerStreamControl, 0},
759+ {"start if needed control", ports.BrokerDaemonStartIfNeeded, ports.BrokerStreamControl, 0},
760+ {"existing only observation", ports.BrokerDaemonExistingOnly, ports.BrokerStreamObservation, 0},
761+ {"start if needed exact attach", ports.BrokerDaemonStartIfNeeded, ports.BrokerStreamAttachment, ports.BrokerAdmissionExact},
762+ } {
763+ t.Run(tc.name, func(t *testing.T) {
764+ open := Open{Ref: testRef(5), Purpose: tc.purpose, Admission: tc.admission, Local: true, Policy: testPolicy(), StartMode: tc.mode}
765+ if tc.admission == ports.BrokerAdmissionExact {
766+ open.Target = testTarget()
767+ }
768+ raw, err := EncodeClient(open, testEnvelopeCeiling, testChunkCeiling)
769+ require.NoError(t, err)
770+ decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
771+ require.NoError(t, err)
772+ admitting, ok := decoded.(Open)
773+ require.True(t, ok)
774+ require.Equal(t, tc.mode, admitting.StartMode)
775+ })
776+ }
777+
778+ t.Run("zero wire code is refused", func(t *testing.T) {
779+ raw := mustEncodeClient(t, Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonExistingOnly})
780+ envelope := &wire.MuxClientEnvelope{}
781+ require.NoError(t, proto.Unmarshal(raw, envelope))
782+ envelope.GetOpen().StartMode = 0
783+ mutated, err := proto.Marshal(envelope)
784+ require.NoError(t, err)
785+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
786+ require.ErrorIs(t, err, ErrInvalidMessage, "an absent start mode is never defaulted")
787+ })
788+
789+ t.Run("unknown wire code is refused", func(t *testing.T) {
790+ raw := mustEncodeClient(t, Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonExistingOnly})
791+ envelope := &wire.MuxClientEnvelope{}
792+ require.NoError(t, proto.Unmarshal(raw, envelope))
793+ envelope.GetOpen().StartMode = 3
794+ mutated, err := proto.Marshal(envelope)
795+ require.NoError(t, err)
796+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
797+ require.ErrorIs(t, err, ErrInvalidMessage)
798+ })
799+
800+ t.Run("encoding a zero start mode is refused", func(t *testing.T) {
801+ _, err := EncodeClient(Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
802+ require.ErrorIs(t, err, ErrInvalidMessage)
803+ })
804+}
805+
734806 // TestMuxAdmissionWireRange proves the MuxOpen decoder narrows the wire
735807 // admission field before any cast: the accepted semantic set (no admission, or
736808 // one of the three closed admission codes) is unchanged, while every wire value
737809 // whose high bits would alias a valid code is refused instead of silently
738810 // truncating onto that code.
739811 func TestMuxAdmissionWireRange(t *testing.T) {
740- control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}
812+ control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded}
741813 named := testOpen()
742814 named.Admission = ports.BrokerAdmissionCreateNamed
743815 named.Name = "work"
M internal/adapters/daemonmux/connector.go +20 -10
@@ -49,11 +49,18 @@ import (
4949 var ErrConnectorConfig = errors.New("daemonmux: invalid endpoint connector configuration")
5050
5151 // RawCarrierDialer establishes one already authenticated raw framed carriage
52-// for an opaque endpoint address. It returns a carriage the daemonmux
52+// for one resolved dial target. It returns a carriage the daemonmux
5353 // FramedCarrierBridge can adapt; it returns promptly once ctx is done. The
5454 // carriage's authentication and transport selection live entirely outside this
5555 // package, so the connector carries no dial policy of its own.
56-type RawCarrierDialer func(ctx context.Context, address string) (RawFramedTransport, error)
56+//
57+// The whole target travels, not only its opaque address: the resolved start
58+// authorization, policy, and endpoint fence are exactly what a carriage owner
59+// needs to decide whether the transport may start the target daemon. They are
60+// passed as one value so a dial can never observe an address that belongs to a
61+// different authorization than the one the connector validated and will verify
62+// in the physical preamble.
63+type RawCarrierDialer func(ctx context.Context, target ports.BrokerDialTarget) (RawFramedTransport, error)
5764
5865 // EndpointConnector implements ports.BrokerEndpointConnector over daemonmux. It
5966 // owns no carriage state itself: every Connect produces one independent
@@ -83,12 +90,15 @@ func NewEndpointConnector(dial RawCarrierDialer, ceilings MuxCeilings) (*Endpoin
8390
8491 // Connect dials the resolved endpoint, completes the daemonmux physical
8592 // preamble against the endpoint's authoritative identity and policy, and
86-// returns a live ports.BrokerPhysicalConnection. The caller's ctx (or its
87-// deadline) bounds dial and handshake together; every failure closes the
88-// carriage, so no half-open physical connection survives. A successful
89-// connection is detached from ctx: cancelling or expiring ctx afterwards never
90-// stops the pooled physical connection, which Close alone owns.
91-func (c *EndpointConnector) Connect(ctx context.Context, endpoint ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) {
93+// returns a live ports.BrokerPhysicalConnection. The resolved target is handed
94+// to the dial function whole, after validation, so the carriage owner sees the
95+// exact start authorization and fence the connector is about to verify. The
96+// caller's ctx (or its deadline) bounds dial and handshake together; every
97+// failure closes the carriage, so no half-open physical connection survives. A
98+// successful connection is detached from ctx: cancelling or expiring ctx
99+// afterwards never stops the pooled physical connection, which Close alone
100+// owns.
101+func (c *EndpointConnector) Connect(ctx context.Context, endpoint ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) {
92102 if c == nil || c.dial == nil {
93103 return nil, ErrConnectorConfig
94104 }
@@ -99,7 +109,7 @@ func (c *EndpointConnector) Connect(ctx context.Context, endpoint ports.BrokerRe
99109 return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
100110 }
101111
102- raw, err := c.dial(ctx, endpoint.Address)
112+ raw, err := c.dial(ctx, endpoint)
103113 if err != nil {
104114 if raw != nil {
105115 _ = raw.Close()
@@ -125,7 +135,7 @@ func (c *EndpointConnector) Connect(ctx context.Context, endpoint ports.BrokerRe
125135 // The endpoint stays authoritative: re-verify the accepted authority even
126136 // though the handshake already checked it, so a future handshake change can
127137 // never weaken what the connector publishes.
128- if result.Identity != endpoint.Identity || !result.Policy.Compatible(endpoint.Policy) || result.Incarnation.Validate() != nil {
138+ if (endpoint.ExpectedIdentity.Bound && result.Identity != endpoint.ExpectedIdentity.Identity) || !result.Policy.Compatible(endpoint.Policy) || result.Incarnation.Validate() != nil {
129139 _ = bridge.Close()
130140 return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible}
131141 }
M internal/adapters/daemonmux/connector_test.go +7 -6
@@ -64,12 +64,13 @@ func muxOpenRequest(stream uint64, policy ports.BrokerPolicy) ports.BrokerOpenSt
6464 Connection: ports.BrokerConnectionID{1},
6565 Stream: ports.BrokerStreamID(stream),
6666 Policy: policy,
67+ StartMode: ports.BrokerDaemonStartIfNeeded,
6768 }
6869 }
6970
7071 // muxEndpoint builds the resolved endpoint the broker verifies against.
71-func muxEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, address string) ports.BrokerResolvedEndpoint {
72- return ports.BrokerResolvedEndpoint{Identity: identity, Policy: policy, Address: address}
72+func muxEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, address string) ports.BrokerDialTarget {
73+ return ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Policy: policy, Address: address, StartMode: ports.BrokerDaemonExistingOnly, ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true}}
7374 }
7475
7576 // superviseServer is a test daemon: an AggregateListener plus a
@@ -114,7 +115,7 @@ func newSuperviseServer(t *testing.T, binding ServerBinding, ceilings MuxCeiling
114115 // dial returns a RawCarrierDialer that produces one fresh real carriage per
115116 // call and adopts its daemon end.
116117 func (s *superviseServer) dial() RawCarrierDialer {
117- return func(_ context.Context, _ string) (RawFramedTransport, error) {
118+ return func(_ context.Context, _ ports.BrokerDialTarget) (RawFramedTransport, error) {
118119 s.dials.Add(1)
119120 client, server := rawCarriagePair(s.t)
120121 go func() { s.adoptions <- s.supervisor.Adopt(context.Background(), server) }()
@@ -179,16 +180,16 @@ func TestNewEndpointConnectorValidation(t *testing.T) {
179180 _, err := NewEndpointConnector(nil, DefaultMuxCeilings())
180181 require.ErrorIs(t, err, ErrConnectorConfig)
181182
182- _, err = NewEndpointConnector(func(context.Context, string) (RawFramedTransport, error) { return nil, nil }, MuxCeilings{})
183+ _, err = NewEndpointConnector(func(context.Context, ports.BrokerDialTarget) (RawFramedTransport, error) { return nil, nil }, MuxCeilings{})
183184 require.ErrorIs(t, err, ErrConnectorConfig)
184185
185- connector, err := NewEndpointConnector(func(context.Context, string) (RawFramedTransport, error) {
186+ connector, err := NewEndpointConnector(func(context.Context, ports.BrokerDialTarget) (RawFramedTransport, error) {
186187 t.Fatal("dial must not run for an invalid endpoint")
187188 return nil, nil
188189 }, DefaultMuxCeilings())
189190 require.NoError(t, err)
190191
191- _, err = connector.Connect(context.Background(), ports.BrokerResolvedEndpoint{})
192+ _, err = connector.Connect(context.Background(), ports.BrokerDialTarget{})
192193 require.Error(t, err)
193194 var typed ports.BrokerError
194195 require.ErrorAs(t, err, &typed)
M internal/adapters/daemonmux/convert.go +26 -0
@@ -44,6 +44,32 @@ func muxEnum16[T ~uint16](value uint32) (T, error) {
4444 return T(value), nil
4545 }
4646
47+// startModeToWire maps the closed daemon-start taxonomy onto its wire code.
48+// The zero value is refused rather than encoded: a peer must never have to
49+// guess whether an absent mode authorized a spawn.
50+func startModeToWire(mode ports.BrokerDaemonStartMode) (uint32, error) {
51+ switch mode {
52+ case ports.BrokerDaemonExistingOnly:
53+ return 1, nil
54+ case ports.BrokerDaemonStartIfNeeded:
55+ return 2, nil
56+ default:
57+ return 0, errConvertRange
58+ }
59+}
60+
61+// startModeFromWire maps a wire daemon-start code onto the closed taxonomy.
62+func startModeFromWire(value uint32) (ports.BrokerDaemonStartMode, error) {
63+ switch value {
64+ case 1:
65+ return ports.BrokerDaemonExistingOnly, nil
66+ case 2:
67+ return ports.BrokerDaemonStartIfNeeded, nil
68+ default:
69+ return 0, errConvertRange
70+ }
71+}
72+
4773 func physicalToWire(id PhysicalStreamID) uint64 { return uint64(id) }
4874
4975 func physicalFromWire(value uint64) (PhysicalStreamID, error) {
M internal/adapters/daemonmux/doc.go +19 -4
@@ -24,11 +24,14 @@
2424 //
2525 // The daemon-side physical binding (binding.go) and the stateful physical
2626 // preamble handshake (handshake.go) are the connection's authority layer. A
27-// ServerBinding is a validated, immutable (identity, incarnation, policy)
28-// value. The broker-side handshake verifies the accepted response against the
27+// ServerBinding is a validated, immutable (identity, incarnation) value over a
28+// non-empty closed set of distinct provisioned policy admissions, each naming
29+// the exact policy for one carriage shape and its locality (ServerPolicyAdmission).
30+// The broker-side handshake verifies the accepted response against the
2931 // independently resolved endpoint identity and policy, the nonzero
3032 // incarnation, and the ceilings it offered; the daemon-side handshake enforces
31-// the immutable binding instead of echoing the requested policy, negotiates
33+// the immutable binding instead of echoing the requested policy, resolves the
34+// accepted member (policy plus origin), negotiates
3235 // the element-wise minima, and refuses with the precise shared rejection code.
3336 // One caller-supplied context or deadline bounds the single request/response
3437 // exchange, and every failure closes the carrier; a success leaves it open for
@@ -101,7 +104,14 @@
101104 // (protocol.HandshakeTimeout) that queue delay and the session handshake share;
102105 // the deadline and the handshake completion signal are exposed to the daemon
103106 // through the narrow optional HandshakePlumbing interface, so the daemon adopts
104-// this one deadline instead of starting a second one. A stream that reaches its
107+// this one deadline instead of starting a second one. Every admitted connection
108+// is stamped with the provisioned admission its listener accepted - the exact
109+// accepted policy and carriage origin plus the peer's declared purpose, closed
110+// admission variant, name, exact target, and bounded environment - and exposes
111+// it through the optional ports.SessionAdmissionProvider so a daemon use case
112+// consumes the accepting side's authority rather than the peer's Open. An Open
113+// whose declared locality contradicts the accepted origin (a liar Local) or that
114+// fails the closed admission contract is refused on its own stream. A stream that reaches its
105115 // deadline, is refused because the accept queue is full, or is reset by the
106116 // peer is settled on its own: exactly one mux Reset, its admission slot
107117 // released, and no effect on a sibling or on the physical connection. An
@@ -212,3 +222,8 @@
212222 // raw remote stderr. daemonmux gains no process, pipe, or framing code from
213223 // this slice.
214224 package daemonmux
225+
226+import "path/filepath"
227+
228+// SocketPath is the canonical production daemonmux endpoint beneath ipc.SocketDir().
229+func SocketPath(socketDir string) string { return filepath.Join(socketDir, "daemonmux.sock") }
M internal/adapters/daemonmux/handshake.go +18 -7
@@ -16,7 +16,7 @@ package daemonmux
1616 //
1717 // The client verifies the accepted response against the independently resolved
1818 // endpoint it dialed: the response identity must exactly equal
19-// ports.BrokerResolvedEndpoint.Identity, the accepted policy must exactly equal
19+// ports.BrokerDialTarget.ExpectedIdentity.Identity, the accepted policy must exactly equal
2020 // the endpoint policy, the incarnation must be nonzero, and the effective
2121 // ceilings must never exceed what the client offered. The response is the
2222 // server's authority, never a value the client supplied and got echoed back.
@@ -97,6 +97,13 @@ type ClientHandshakeResult struct {
9797 type ServerHandshakeResult struct {
9898 Ceilings MuxCeilings
9999 Binding ServerBinding
100+ // Policy is the exact immutable authority member admitted for this peer.
101+ Policy ports.BrokerPolicy
102+ // Origin is the provisioned locality of the admitted member, carried from
103+ // the same provisioned entry Policy was resolved from. A downstream listener
104+ // stamps it on every admitted session connection so a daemon use case can
105+ // enforce the local/remote locality bound without re-deriving it.
106+ Origin ports.SessionConnectionOrigin
100107 }
101108
102109 // RunClientHandshake performs the broker side of the daemonmux physical
@@ -108,7 +115,7 @@ type ServerHandshakeResult struct {
108115 // authority mismatch. The caller's ctx (or its deadline) bounds both the send
109116 // and the receive. Every failure closes carrier; success leaves it open and
110117 // does not start a pump.
111-func RunClientHandshake(ctx context.Context, carrier FramedCarrier, endpoint ports.BrokerResolvedEndpoint, offered MuxCeilings) (ClientHandshakeResult, error) {
118+func RunClientHandshake(ctx context.Context, carrier FramedCarrier, endpoint ports.BrokerDialTarget, offered MuxCeilings) (ClientHandshakeResult, error) {
112119 if ctx == nil {
113120 ctx = context.Background()
114121 }
@@ -168,11 +175,14 @@ func RunClientHandshake(ctx context.Context, carrier FramedCarrier, endpoint por
168175 // incarnation is nonzero, and the effective ceilings never exceed the offer.
169176 // A non-acceptance is refused too, so a caller can never treat a refusal as an
170177 // accepted empty response.
171-func validateClientResponse(response MuxPreambleResponse, endpoint ports.BrokerResolvedEndpoint, offered MuxCeilings) error {
178+func validateClientResponse(response MuxPreambleResponse, endpoint ports.BrokerDialTarget, offered MuxCeilings) error {
172179 if !response.Accepted {
173180 return ErrHandshakeRejected
174181 }
175- if response.Identity != endpoint.Identity {
182+ if endpoint.ExpectedIdentity.Bound && response.Identity != endpoint.ExpectedIdentity.Identity {
183+ return ErrHandshakeRejected
184+ }
185+ if err := response.Identity.Validate(); err != nil {
176186 return ErrHandshakeRejected
177187 }
178188 if !response.Policy.Compatible(endpoint.Policy) {
@@ -222,7 +232,8 @@ func RunServerHandshake(ctx context.Context, carrier FramedCarrier, binding Serv
222232 closeFailedHandshake(carrier)
223233 return ServerHandshakeResult{}, &HandshakeRefusal{Code: code}
224234 }
225- if !binding.Accepts(request.Policy) {
235+ accepted, ok := binding.Accepted(request.Policy)
236+ if !ok {
226237 code := uint32(RejectionLimitRefused)
227238 _ = sendHandshakeRefusal(ctx, carrier, offered, code)
228239 closeFailedHandshake(carrier)
@@ -230,7 +241,7 @@ func RunServerHandshake(ctx context.Context, carrier FramedCarrier, binding Serv
230241 }
231242
232243 effective := EffectiveMuxCeilings(offered, request.Ceilings)
233- response, err := EncodePreambleResponse(true, effective, binding.Policy(), binding.Identity(), binding.Incarnation(), 0)
244+ response, err := EncodePreambleResponse(true, effective, accepted.Policy, binding.Identity(), binding.Incarnation(), 0)
234245 if err != nil {
235246 closeFailedHandshake(carrier)
236247 return ServerHandshakeResult{}, ErrHandshakeConfig
@@ -244,7 +255,7 @@ func RunServerHandshake(ctx context.Context, carrier FramedCarrier, binding Serv
244255 closeFailedHandshake(carrier)
245256 return ServerHandshakeResult{}, err
246257 }
247- return ServerHandshakeResult{Ceilings: effective, Binding: binding}, nil
258+ return ServerHandshakeResult{Ceilings: effective, Binding: binding, Policy: accepted.Policy, Origin: accepted.Origin}, nil
248259 }
249260
250261 // decodePreambleRequestWithCode is the server's strict request decode paired
M internal/adapters/daemonmux/handshake_test.go +14 -2
@@ -15,8 +15,8 @@ import (
1515
1616 // handshakeEndpoint is the independently resolved endpoint the client verifies
1717 // the daemon response against.
18-func handshakeEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy) ports.BrokerResolvedEndpoint {
19- return ports.BrokerResolvedEndpoint{Identity: identity, Policy: policy, Address: "quic://127.0.0.1:7777"}
18+func handshakeEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy) ports.BrokerDialTarget {
19+ return ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Policy: policy, Address: "quic://127.0.0.1:7777", StartMode: ports.BrokerDaemonExistingOnly, ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true}}
2020 }
2121
2222 func mustServerBinding(t *testing.T) ServerBinding {
@@ -26,6 +26,16 @@ func mustServerBinding(t *testing.T) ServerBinding {
2626 return binding
2727 }
2828
29+// mustRemoteServerBinding provisions the same authority with the remote
30+// locality, for tests whose Open requests carry a validated remote
31+// registration rather than a local endpoint.
32+func mustRemoteServerBinding(t *testing.T) ServerBinding {
33+ t.Helper()
34+ binding, err := NewServerBindings(testIdentity(), testIncarnation(), []ServerPolicyAdmission{remoteAcceptance(testPolicy())})
35+ require.NoError(t, err)
36+ return binding
37+}
38+
2939 // encodedAcceptedResponse builds one valid acceptance and lets the caller
3040 // corrupt exactly one field before marshaling.
3141 func encodedAcceptedResponse(t *testing.T, mutate func(*wire.MuxPreambleResponse)) []byte {
@@ -274,6 +284,8 @@ func TestServerHandshakeAcceptsAndNegotiates(t *testing.T) {
274284 require.NoError(t, err)
275285 require.Equal(t, binding, result.Binding)
276286 require.Equal(t, EffectiveMuxCeilings(clientCeilings, serverCeilings), result.Ceilings)
287+ require.Equal(t, testPolicy(), result.Policy, "the accepted policy is the provisioned member")
288+ require.Equal(t, ports.SessionOriginLocal, result.Origin, "the accepted origin is the provisioned locality")
277289 require.Equal(t, 0, carrier.closeCalls())
278290
279291 decoded, err := DecodePreambleResponseBytes(awaitSentFrame(t, carrier))
M internal/adapters/daemonmux/listener.go +107 -27
@@ -103,6 +103,13 @@ const (
103103 listenerClosedText = "listener closed"
104104 handshakeDeadlineText = "handshake deadline exceeded"
105105
106+ // invalidAdmissionText is the bounded, presentation-safe text of the
107+ // stream-local Reset a listener sends when an admitted Open contradicts the
108+ // accepted carriage origin (a liar Local) or fails the closed admission
109+ // contract. The stream is refused rather than stamped with a provenance the
110+ // accepting side did not provision.
111+ invalidAdmissionText = "invalid session admission"
112+
106113 // sessionCarriageClosedText is the bounded, presentation-safe text carried
107114 // by the stream-local Reset a listener connection sends when its inner
108115 // session carriage closed on its own during the handshake (a sessionwire
@@ -137,6 +144,11 @@ type Listener struct {
137144 clock ports.Clock
138145 budget time.Duration
139146 queueLimit int
147+ // accepted is the provisioned carriage shape the physical handshake
148+ // admitted. Every admitted session connection is stamped with it, so the
149+ // daemon consumes the accepting side's authority rather than the peer's
150+ // Open.
151+ accepted ServerPolicyAdmission
140152
141153 // mu guards the accept queue, the deadline registry, and the closed flag.
142154 // change is the closed-and-replaced broadcast channel every blocked Accept
@@ -154,46 +166,56 @@ type Listener struct {
154166 }
155167
156168 var (
157- _ ports.ServerListener = (*Listener)(nil)
158- _ ports.ServerConnection = (*ListenerConnection)(nil)
159- _ HandshakePlumbing = (*ListenerConnection)(nil)
169+ _ ports.ServerListener = (*Listener)(nil)
170+ _ ports.ServerConnection = (*ListenerConnection)(nil)
171+ _ HandshakePlumbing = (*ListenerConnection)(nil)
172+ _ ports.SessionAdmissionProvider = (*ListenerConnection)(nil)
160173 )
161174
162175 // admittedStream is one inbound logical stream the engine admitted and the
163176 // listener tracks until it is settled: delivered with a completed handshake, or
164-// terminal (expired, peer-settled, refused, or released).
177+// terminal (expired, peer-settled, refused, or released). admission is the
178+// cloned provisioned admission stamped when the stream was admitted.
165179 type admittedStream struct {
166- ref StreamRef
167- deadline time.Time
168- done <-chan struct{}
169- queued bool
170- settled bool
180+ ref StreamRef
181+ deadline time.Time
182+ done <-chan struct{}
183+ admission ports.SessionAdmission
184+ queued bool
185+ settled bool
171186 }
172187
173188 // NewListener returns a listener over one daemon-side pump under the package
174189 // policy: the accepted absolute handshake deadline budget is
175190 // protocol.HandshakeTimeout, and the accept queue is bounded by
176-// MaxAcceptQueue. The pump must decode broker-to-daemon frames
177-// (Inbound == DirectionClient) and emit daemon-to-broker frames, as the daemon
178-// side of a pooled physical connection does; a nil, engine-less, or
179-// broker-side pump is refused. The pump must not have started: the listener
180-// registers the pump's admission observer, and a pump that already started
181-// refuses the registration with ErrAdmissionObserverLate (wrapped in
182-// ErrListenerConfig), because an observer registered after Start could miss an
183-// already-admitted Open.
184-func NewListener(pump *Pump) (*Listener, error) {
185- return newListener(pump, protocol.HandshakeTimeout, MaxAcceptQueue, clock.New())
191+// MaxAcceptQueue. accepted is the provisioned carriage shape the physical
192+// handshake admitted: its exact policy and its locality origin. Every admitted
193+// session connection is stamped with it. The pump must decode broker-to-daemon
194+// frames (Inbound == DirectionClient) and emit daemon-to-broker frames, as the
195+// daemon side of a pooled physical connection does; a nil, engine-less, or
196+// broker-side pump, or an invalid accepted entry, is refused. The pump must not
197+// have started: the listener registers the pump's admission observer, and a
198+// pump that already started refuses the registration with
199+// ErrAdmissionObserverLate (wrapped in ErrListenerConfig), because an observer
200+// registered after Start could miss an already-admitted Open.
201+func NewListener(pump *Pump, accepted ServerPolicyAdmission) (*Listener, error) {
202+ return newListener(pump, protocol.HandshakeTimeout, MaxAcceptQueue, clock.New(), accepted)
186203 }
187204
188205 // newListener builds a listener from explicit policy. budget is the handshake
189206 // deadline budget each stream receives at admission, limit is the accept-queue
190-// bound, and clock supplies the admission time and the deadline timer. The
191-// exported constructor delegates with the package policy; a test tightens the
192-// budget, the queue bound, or the clock without changing live behavior.
193-func newListener(pump *Pump, budget time.Duration, limit int, timeSource ports.Clock) (*Listener, error) {
207+// bound, clock supplies the admission time and the deadline timer, and accepted
208+// is the provisioned carriage shape stamped on every admitted session
209+// connection. The exported constructor delegates with the package policy; a
210+// test tightens the budget, the queue bound, or the clock without changing live
211+// behavior.
212+func newListener(pump *Pump, budget time.Duration, limit int, timeSource ports.Clock, accepted ServerPolicyAdmission) (*Listener, error) {
194213 if pump == nil || pump.Engine() == nil || pump.Inbound() != DirectionClient {
195214 return nil, ErrListenerConfig
196215 }
216+ if err := accepted.Validate(); err != nil {
217+ return nil, fmt.Errorf("%w: %w", ErrListenerConfig, err)
218+ }
197219 if budget <= 0 {
198220 budget = protocol.HandshakeTimeout
199221 }
@@ -208,6 +230,7 @@ func newListener(pump *Pump, budget time.Duration, limit int, timeSource ports.C
208230 clock: timeSource,
209231 budget: budget,
210232 queueLimit: limit,
233+ accepted: accepted,
211234 active: make(map[PhysicalStreamID]*admittedStream),
212235 change: make(chan struct{}),
213236 done: make(chan struct{}),
@@ -294,16 +317,60 @@ func (l *Listener) Close() error {
294317 return nil
295318 }
296319
320+// sessionAdmissionFor stamps the provisioned admission metadata onto one
321+// admitted Open: the accepted entry's exact policy and origin, plus the peer's
322+// closed purpose, attachment variant, name, target, and bounded environment. It
323+// refuses an Open whose declared locality contradicts the accepted origin (a
324+// liar Local must never be stamped as the provisioned locality), whose offered
325+// policy is not the accepted member, or whose shape the closed admission
326+// contract rejects, so a consumer only ever receives a validated admission. The
327+// policy and locality checks are repeated here rather than relying on the
328+// engine's prior restriction, so a listener built without one still enforces
329+// the accepted member.
330+func sessionAdmissionFor(accepted ServerPolicyAdmission, open Open) (ports.SessionAdmission, error) {
331+ if !accepted.Policy.Compatible(open.Policy) {
332+ return ports.SessionAdmission{}, ErrInvalidMessage
333+ }
334+ origin := ports.SessionOriginRemote
335+ if open.Local {
336+ origin = ports.SessionOriginLocal
337+ }
338+ if accepted.Origin != origin {
339+ return ports.SessionAdmission{}, ErrInvalidMessage
340+ }
341+ admission := ports.SessionAdmission{
342+ Origin: accepted.Origin,
343+ Policy: accepted.Policy,
344+ Purpose: open.Purpose,
345+ Admission: open.Admission,
346+ Name: open.Name,
347+ Target: open.Target,
348+ Env: append([]string(nil), open.Env...),
349+ }
350+ if err := admission.Validate(); err != nil {
351+ return ports.SessionAdmission{}, ErrInvalidMessage
352+ }
353+ return admission, nil
354+}
355+
297356 // onAdmitted is the pump's admission observer. It runs on the pump's reader
298-// goroutine immediately after one inbound Open was admitted: it queues the
357+// goroutine immediately after one inbound Open was admitted: it stamps the
358+// provisioned admission (refusing exactly that stream when the Open contradicts
359+// the accepted origin or fails the closed admission contract) and queues the
299360 // stream under its admission deadline, or refuses exactly that stream when the
300361 // accept queue is full or the listener is closed. It never blocks - the reader
301362 // is the one goroutine that applies every inbound frame of the connection.
302363 func (l *Listener) onAdmitted(open Open) {
303364 admitted := l.clock.Now()
365+ admission, err := sessionAdmissionFor(l.accepted, open)
366+ if err != nil {
367+ l.abort(open.Ref.Physical, invalidAdmissionText)
368+ return
369+ }
304370 entry := &admittedStream{
305- ref: open.Ref,
306- deadline: admitted.Add(l.budget),
371+ ref: open.Ref,
372+ deadline: admitted.Add(l.budget),
373+ admission: admission,
307374 }
308375 if status, ok := l.pump.Engine().Status(open.Ref.Physical); ok {
309376 entry.done = status.Done
@@ -620,6 +687,10 @@ type ListenerConnection struct {
620687 // admission, resolved once at construction.
621688 deadline time.Time
622689
690+ // admission is the cloned provisioned admission stamped at admission. It is
691+ // resolved once at construction and exposed only through a defensive copy.
692+ admission ports.SessionAdmission
693+
623694 terminal *terminalState
624695 watchDone chan struct{}
625696 closeOnce sync.Once
@@ -636,7 +707,7 @@ func newListenerConnection(l *Listener, entry *admittedStream) *ListenerConnecti
636707 pipe := newMuxStreamPipe(l.pump, entry.ref.Physical, limit, entry.done)
637708 raw := newMuxTransport(pipe)
638709 connection := &ListenerConnection{
639- ServerConnection: sessionwire.NewServerConnectionWithDeadline(raw, entry.deadline),
710+ ServerConnection: sessionwire.NewServerConnectionWithAdmission(raw, entry.deadline, entry.admission),
640711 listener: l,
641712 pump: l.pump,
642713 ref: entry.ref,
@@ -645,6 +716,7 @@ func newListenerConnection(l *Listener, entry *admittedStream) *ListenerConnecti
645716 stream: entry.done,
646717 cause: l.pump.cause(entry.ref.Physical),
647718 deadline: entry.deadline,
719+ admission: entry.admission.Clone(),
648720 terminal: newTerminalState(),
649721 watchDone: make(chan struct{}),
650722 }
@@ -660,6 +732,14 @@ func newListenerConnection(l *Listener, entry *admittedStream) *ListenerConnecti
660732 // Ref returns the complete stream reference recorded at admission.
661733 func (c *ListenerConnection) Ref() StreamRef { return c.ref }
662734
735+// SessionAdmission returns a defensive copy of the provisioned admission
736+// stamped at acceptance, implementing ports.SessionAdmissionProvider. It always
737+// reports ok=true: a ListenerConnection is built only from an already-admitted
738+// stream whose provenance a listener stamped and validated.
739+func (c *ListenerConnection) SessionAdmission() (ports.SessionAdmission, bool) {
740+ return c.admission.Clone(), true
741+}
742+
663743 // HandshakeDeadline returns the accepted absolute local deadline of the
664744 // session handshake, fixed at Open admission and plumbing the exact deadline
665745 // the wrapped session connection started with. Queue delay and the handshake
M internal/adapters/daemonmux/listener_test.go +12 -5
@@ -179,7 +179,7 @@ func newListenerHarnessWithCeilings(t *testing.T, budget time.Duration, queueLim
179179 t.Helper()
180180 brokerPump, daemonPump, start := newPairedPumpsUnstarted(t, ceilings)
181181 clock := newListenerClock(time.Now())
182- listener, err := newListener(daemonPump, budget, queueLimit, clock)
182+ listener, err := newListener(daemonPump, budget, queueLimit, clock, localAcceptance(logicalTestPolicy()))
183183 require.NoError(t, err)
184184 t.Cleanup(func() { _ = listener.Close() })
185185 start()
@@ -351,20 +351,27 @@ func (d *listenerDaemon) await(t *testing.T, count int) {
351351 }
352352
353353 func TestNewListenerValidation(t *testing.T) {
354+ local := localAcceptance(testPolicy())
354355 t.Run("nil pump", func(t *testing.T) {
355- listener, err := NewListener(nil)
356+ listener, err := NewListener(nil, local)
356357 require.ErrorIs(t, err, ErrListenerConfig)
357358 require.Nil(t, listener)
358359 })
359360 t.Run("broker-side pump is refused", func(t *testing.T) {
360361 pump, _ := newTestPump(t, DirectionServer)
361- listener, err := NewListener(pump)
362+ listener, err := NewListener(pump, local)
363+ require.ErrorIs(t, err, ErrListenerConfig)
364+ require.Nil(t, listener)
365+ })
366+ t.Run("invalid accepted entry is refused", func(t *testing.T) {
367+ pump, _ := newTestPump(t, DirectionClient)
368+ listener, err := NewListener(pump, ServerPolicyAdmission{})
362369 require.ErrorIs(t, err, ErrListenerConfig)
363370 require.Nil(t, listener)
364371 })
365372 t.Run("daemon-side pump is accepted", func(t *testing.T) {
366373 pump, _ := newTestPump(t, DirectionClient)
367- listener, err := NewListener(pump)
374+ listener, err := NewListener(pump, local)
368375 require.NoError(t, err)
369376 require.NotNil(t, listener)
370377 require.Equal(t, "daemonmux", listener.Addr())
@@ -1211,7 +1218,7 @@ func TestNewListenerRefusesStartedPump(t *testing.T) {
12111218 pump, _ := newTestPump(t, DirectionClient)
12121219 pump.Start(context.Background())
12131220
1214- listener, err := NewListener(pump)
1221+ listener, err := NewListener(pump, localAcceptance(testPolicy()))
12151222 require.ErrorIs(t, err, ErrListenerConfig)
12161223 require.ErrorIs(t, err, ErrAdmissionObserverLate)
12171224 require.Nil(t, listener)
M internal/adapters/daemonmux/logical_conn.go +4 -0
@@ -175,6 +175,10 @@ func openMessage(physical PhysicalStreamID, request ports.BrokerOpenStreamReques
175175 Target: request.Target,
176176 Env: append([]string(nil), request.Env...),
177177 Policy: request.Policy,
178+ // The daemon-start authorization travels with the mux Open, so the
179+ // daemon-side transport receives exactly the authority the broker
180+ // resolved and can never widen it into a spawn.
181+ StartMode: request.StartMode,
178182 }
179183 }
180184
M internal/adapters/daemonmux/logical_conn_test.go +1 -0
@@ -134,6 +134,7 @@ func controlRequest(stream int) ports.BrokerOpenStreamRequest {
134134 Connection: ports.BrokerConnectionID{1},
135135 Stream: ports.BrokerStreamID(stream),
136136 Policy: logicalTestPolicy(),
137+ StartMode: ports.BrokerDaemonStartIfNeeded,
137138 }
138139 }
139140
M internal/adapters/daemonmux/messages.go +4 -1
@@ -76,7 +76,9 @@ func (r StreamRef) Validate() error {
7676
7777 // Open asks the daemon to open one independently cancellable logical stream
7878 // over the physical connection. Env is the per-request session environment
79-// and is never inherited from the broker process environment.
79+// and is never inherited from the broker process environment. StartMode is
80+// the broker's explicit daemon-start authorization, propagated unchanged so a
81+// transport can never widen it into a spawn.
8082 type Open struct {
8183 Ref StreamRef
8284 Purpose ports.BrokerStreamPurpose
@@ -88,6 +90,7 @@ type Open struct {
8890 Target protocol.ExactSessionTarget
8991 Env []string
9092 Policy ports.BrokerPolicy
93+ StartMode ports.BrokerDaemonStartMode
9194 }
9295
9396 func (Open) muxClientMessage() {}
M internal/adapters/daemonmux/pool_integration_test.go +18 -11
@@ -13,16 +13,22 @@ import (
1313 "github.com/bnema/vev/internal/usecase/broker"
1414 )
1515
16-// p3cResolver is a test BrokerEndpointResolver: it returns one authenticated
16+// p3cResolver is a test BrokerRouteAuthority: it returns one authenticated
1717 // endpoint per request, keyed on the request's registration endpoint so two
1818 // compatible aliases resolve to the same (identity, policy) physical key with
1919 // distinct adapter routes.
20-type p3cResolver func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error)
20+type p3cResolver func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error)
2121
22-func (f p3cResolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
22+func (f p3cResolver) ResolveDialTarget(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) {
2323 return f(ctx, request)
2424 }
2525
26+type p3cBinder struct{}
27+
28+func (p3cBinder) BindAuthenticatedIdentity(_ context.Context, request ports.BrokerIdentityBindingRequest) (ports.BrokerDaemonIdentity, error) {
29+ return request.Identity, nil
30+}
31+
2632 // p3cRemoteRequest builds one valid remote control-purpose request.
2733 func p3cRemoteRequest(connection ports.BrokerConnectionID, endpoint string, stream uint64, policy ports.BrokerPolicy) ports.BrokerOpenStreamRequest {
2834 registration := domain.RemoteRegistration{
@@ -39,6 +45,7 @@ func p3cRemoteRequest(connection ports.BrokerConnectionID, endpoint string, stre
3945 Endpoint: endpoint,
4046 Registration: registration,
4147 Policy: policy,
48+ StartMode: ports.BrokerDaemonStartIfNeeded,
4249 }
4350 }
4451
@@ -51,17 +58,17 @@ func p3cPoolLimits() broker.PoolLimits {
5158 // compatible aliases over exactly one daemonmux physical connection: only one
5259 // carriage is dialed, and both logical streams flow over it.
5360 func TestPoolSharesCompatibleAliasesOverOnePhysical(t *testing.T) {
54- binding := mustServerBinding(t)
61+ binding := mustRemoteServerBinding(t)
5562 policy := binding.Policy()
5663 server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil)
5764 server.serve()
58- resolver := p3cResolver(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
65+ resolver := p3cResolver(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) {
5966 return muxEndpoint(binding.Identity(), policy, "raw://"+request.Endpoint), nil
6067 })
6168
6269 connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings())
6370 require.NoError(t, err)
64- pool, err := broker.NewPool(1, resolver, connector, newListenerClock(time.Now()), p3cPoolLimits())
71+ pool, err := broker.NewPool(1, resolver, p3cBinder{}, connector, newListenerClock(time.Now()), p3cPoolLimits())
6572 require.NoError(t, err)
6673 t.Cleanup(func() { _ = pool.Close() })
6774
@@ -95,13 +102,13 @@ func TestPoolSharesCompatibleAliasesOverOnePhysical(t *testing.T) {
95102 func TestPoolRefusesConflictingPolicyWithoutDial(t *testing.T) {
96103 binding := mustServerBinding(t)
97104 server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil)
98- resolver := p3cResolver(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
105+ resolver := p3cResolver(func(_ context.Context, _ ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) {
99106 return muxEndpoint(binding.Identity(), binding.Policy(), "raw://conflict"), nil
100107 })
101108
102109 connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings())
103110 require.NoError(t, err)
104- pool, err := broker.NewPool(1, resolver, connector, newListenerClock(time.Now()), p3cPoolLimits())
111+ pool, err := broker.NewPool(1, resolver, p3cBinder{}, connector, newListenerClock(time.Now()), p3cPoolLimits())
105112 require.NoError(t, err)
106113 t.Cleanup(func() { _ = pool.Close() })
107114
@@ -119,17 +126,17 @@ func TestPoolRefusesConflictingPolicyWithoutDial(t *testing.T) {
119126 // per affected logical stream through the pool: the pooled stream terminates
120127 // with a broker stream-lost error once the accepted physical carriage dies.
121128 func TestPoolSurfacesPhysicalLossPerStream(t *testing.T) {
122- binding := mustServerBinding(t)
129+ binding := mustRemoteServerBinding(t)
123130 policy := binding.Policy()
124131 server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil)
125132 server.serve()
126- resolver := p3cResolver(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
133+ resolver := p3cResolver(func(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) {
127134 return muxEndpoint(binding.Identity(), policy, "raw://"+request.Endpoint), nil
128135 })
129136
130137 connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings())
131138 require.NoError(t, err)
132- pool, err := broker.NewPool(1, resolver, connector, newListenerClock(time.Now()), p3cPoolLimits())
139+ pool, err := broker.NewPool(1, resolver, p3cBinder{}, connector, newListenerClock(time.Now()), p3cPoolLimits())
133140 require.NoError(t, err)
134141 t.Cleanup(func() { _ = pool.Close() })
135142
M internal/adapters/daemonmux/quic_carriage_test.go +3 -3
@@ -54,8 +54,8 @@ func quicCarriageEndpoint(t *testing.T) (*quic.Server, quic.Readiness, string) {
5454 // single-use token/nonce - and hands the raw bounded transport to the connector
5555 // unmodified.
5656 func quicDialer(readiness quic.Readiness) RawCarrierDialer {
57- return func(ctx context.Context, address string) (RawFramedTransport, error) {
58- raw, err := quic.DialMuxContext(ctx, address, readiness, quic.Config{}, quicCarriageDeadline)
57+ return func(ctx context.Context, target ports.BrokerDialTarget) (RawFramedTransport, error) {
58+ raw, err := quic.DialMuxContext(ctx, target.Address, readiness, quic.Config{}, quicCarriageDeadline)
5959 if err != nil {
6060 return nil, err
6161 }
@@ -66,7 +66,7 @@ func quicDialer(readiness quic.Readiness) RawCarrierDialer {
6666 // quicDialCarriage establishes the broker end of one real QUIC mux carriage.
6767 func quicDialCarriage(t *testing.T, ctx context.Context, addr string, readiness quic.Readiness) RawFramedTransport {
6868 t.Helper()
69- raw, err := quicDialer(readiness)(ctx, addr)
69+ raw, err := quicDialer(readiness)(ctx, ports.BrokerDialTarget{Address: addr})
7070 require.NoError(t, err)
7171 t.Cleanup(func() { _ = raw.Close() })
7272 return raw
M internal/adapters/daemonmux/ssh_carriage_test.go +1 -1
@@ -183,7 +183,7 @@ func sshMuxHelperCommand(mode string, extra ...string) sshstdio.CommandSpec {
183183 // specification and a counter of how many physical carriages it opened.
184184 func sshMuxDialer(spec sshstdio.CommandSpec) (RawCarrierDialer, *atomic.Int32) {
185185 dials := &atomic.Int32{}
186- return func(ctx context.Context, _ string) (RawFramedTransport, error) {
186+ return func(ctx context.Context, _ ports.BrokerDialTarget) (RawFramedTransport, error) {
187187 dials.Add(1)
188188 return sshstdio.DialMuxContext(ctx, spec, nil)
189189 }, dials
M internal/adapters/daemonmux/supervisor.go +6 -3
@@ -177,13 +177,16 @@ func (s *ServerSupervisor) Adopt(ctx context.Context, raw RawFramedTransport) er
177177 }
178178 // Enforce the accepted physical policy before any fresh inbound Open can be
179179 // admitted: the pump's engine must be restricted before the pump starts,
180- // and the listener below is built before that start too.
181- if err := pump.Engine().RestrictAdmissions(result.Binding.Policy()); err != nil {
180+ // and the listener below is built before that start too. The accepted entry
181+ // carries both the exact policy the engine enforces and the locality origin
182+ // the listener stamps on every admitted session connection.
183+ accepted := ServerPolicyAdmission{Policy: result.Policy, Origin: result.Origin}
184+ if err := pump.Engine().RestrictAdmissions(accepted.Policy); err != nil {
182185 _ = pump.Close()
183186 return err
184187 }
185188
186- listener, err := newListener(pump, protocol.HandshakeTimeout, MaxAcceptQueue, s.clock)
189+ listener, err := newListener(pump, protocol.HandshakeTimeout, MaxAcceptQueue, s.clock, accepted)
187190 if err != nil {
188191 _ = pump.Close()
189192 return err
M internal/adapters/daemonmux/supervisor_test.go +159 -3
@@ -7,15 +7,16 @@ import (
77
88 "github.com/stretchr/testify/require"
99
10+ "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/ports"
1112 "github.com/bnema/vev/internal/protocol"
1213 )
1314
1415 // connectRaw builds one broker PhysicalConnection over an already established
1516 // raw carriage whose daemon end was handed to a supervisor.
16-func connectRaw(t *testing.T, raw RawFramedTransport, endpoint ports.BrokerResolvedEndpoint) ports.BrokerPhysicalConnection {
17+func connectRaw(t *testing.T, raw RawFramedTransport, endpoint ports.BrokerDialTarget) ports.BrokerPhysicalConnection {
1718 t.Helper()
18- connector, err := NewEndpointConnector(func(context.Context, string) (RawFramedTransport, error) {
19+ connector, err := NewEndpointConnector(func(context.Context, ports.BrokerDialTarget) (RawFramedTransport, error) {
1920 return raw, nil
2021 }, DefaultMuxCeilings())
2122 require.NoError(t, err)
@@ -28,7 +29,7 @@ func connectRaw(t *testing.T, raw RawFramedTransport, endpoint ports.BrokerResol
2829
2930 // awaitHandshakeBridge performs the broker side of the daemonmux physical
3031 // preamble over raw and returns the negotiated carrier, ready for a pump.
31-func awaitHandshakeBridge(t *testing.T, raw RawFramedTransport, endpoint ports.BrokerResolvedEndpoint) (FramedCarrier, ClientHandshakeResult) {
32+func awaitHandshakeBridge(t *testing.T, raw RawFramedTransport, endpoint ports.BrokerDialTarget) (FramedCarrier, ClientHandshakeResult) {
3233 t.Helper()
3334 bridge, err := NewPreambleCarrier(raw)
3435 require.NoError(t, err)
@@ -117,6 +118,161 @@ func TestServerSupervisorEnforcesAcceptedPolicyBeforeAdmission(t *testing.T) {
117118 require.Equal(t, protocol.Pong{}, message)
118119 }
119120
121+// TestServerSupervisorDeliversFirstHelloDataFrame proves the complete first
122+// application-frame path without timing probes: LogicalConnection.SendClient
123+// writes Hello through sessionwire, the physical pump emits Data, the
124+// supervisor-owned daemon pump dispatches it, and the accepted logical server
125+// connection's sessionwire ReceiveClient decodes that same Hello. This guards
126+// the Opened-to-first-Data boundary where losing a wakeup or using the wrong
127+// direction/physical ID would otherwise leave the daemon blocked forever.
128+func TestServerSupervisorDeliversFirstHelloDataFrame(t *testing.T) {
129+ binding := mustServerBinding(t)
130+ aggregate := NewAggregateListener()
131+ supervisor, err := NewServerSupervisor(aggregate, binding, DefaultMuxCeilings(), 1)
132+ require.NoError(t, err)
133+ t.Cleanup(func() {
134+ _ = supervisor.Close()
135+ _ = aggregate.Close()
136+ })
137+
138+ clientRaw, serverRaw := rawCarriagePair(t)
139+ adopted := make(chan error, 1)
140+ go func() { adopted <- supervisor.Adopt(context.Background(), serverRaw) }()
141+ physical := connectRaw(t, clientRaw, muxEndpoint(binding.Identity(), binding.Policy(), "raw://first-hello"))
142+ require.NoError(t, <-adopted)
143+
144+ accepted := make(chan ports.ServerConnection, 1)
145+ acceptErr := make(chan error, 1)
146+ go func() {
147+ connection, err := aggregate.Accept()
148+ if err != nil {
149+ acceptErr <- err
150+ return
151+ }
152+ accepted <- connection
153+ }()
154+
155+ logical, err := physical.OpenStream(context.Background(), muxOpenRequest(1, binding.Policy()))
156+ require.NoError(t, err)
157+ var server ports.ServerConnection
158+ select {
159+ case server = <-accepted:
160+ case err := <-acceptErr:
161+ require.NoError(t, err)
162+ case <-time.After(p3cTestDeadline):
163+ t.Fatal("supervisor did not dispatch the admitted logical stream")
164+ }
165+
166+ hello := protocol.Hello{Version: protocol.Version, Intent: protocol.IntentEphemeral, Size: domain.Size{Cols: 80, Rows: 24}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned}
167+ sent := make(chan error, 1)
168+ received := make(chan protocol.ClientMessage, 1)
169+ receiveErr := make(chan error, 1)
170+ go func() { sent <- logical.SendClient(hello) }()
171+ go func() {
172+ message, err := server.ReceiveClient()
173+ if err != nil {
174+ receiveErr <- err
175+ return
176+ }
177+ received <- message
178+ }()
179+ select {
180+ case message := <-received:
181+ require.Equal(t, hello, message)
182+ case err := <-receiveErr:
183+ require.NoError(t, err)
184+ case <-time.After(p3cTestDeadline):
185+ t.Fatal("first Hello Data frame did not reach sessionwire ReceiveClient")
186+ }
187+ require.NoError(t, <-sent)
188+}
189+
190+// TestServerSupervisorStampsAcceptedOriginThroughAggregate proves the accepted
191+// physical origin the supervisor resolved from the binding's provisioned member
192+// reaches the daemon end-to-end: the aggregate hands back the same typed
193+// connection the child listener produced, and its admission provider reports the
194+// exact accepted policy and origin. It guards the wrapper chain so a later
195+// wrapper can never strip a child's admission metadata.
196+func TestServerSupervisorStampsAcceptedOriginThroughAggregate(t *testing.T) {
197+ binding := mustServerBinding(t)
198+ aggregate := NewAggregateListener()
199+ supervisor, err := NewServerSupervisor(aggregate, binding, DefaultMuxCeilings(), 1)
200+ require.NoError(t, err)
201+ t.Cleanup(func() {
202+ _ = supervisor.Close()
203+ _ = aggregate.Close()
204+ })
205+
206+ clientRaw, serverRaw := rawCarriagePair(t)
207+ adopted := make(chan error, 1)
208+ go func() { adopted <- supervisor.Adopt(context.Background(), serverRaw) }()
209+ physical := connectRaw(t, clientRaw, muxEndpoint(binding.Identity(), binding.Policy(), "raw://origin"))
210+ require.NoError(t, <-adopted)
211+
212+ accepted := make(chan ports.ServerConnection, 1)
213+ acceptErr := make(chan error, 1)
214+ go func() {
215+ connection, err := aggregate.Accept()
216+ if err != nil {
217+ acceptErr <- err
218+ return
219+ }
220+ accepted <- connection
221+ }()
222+
223+ logical, err := physical.OpenStream(context.Background(), muxOpenRequest(1, binding.Policy()))
224+ require.NoError(t, err)
225+ t.Cleanup(func() { _ = logical.Close() })
226+
227+ var server ports.ServerConnection
228+ select {
229+ case server = <-accepted:
230+ case err := <-acceptErr:
231+ require.NoError(t, err)
232+ case <-time.After(p3cTestDeadline):
233+ t.Fatal("supervisor did not dispatch the admitted logical stream")
234+ }
235+
236+ provider, ok := server.(ports.SessionAdmissionProvider)
237+ require.True(t, ok, "the accepted connection preserves the admission provider through the aggregate")
238+ admission, ok := provider.SessionAdmission()
239+ require.True(t, ok)
240+ require.Equal(t, ports.SessionOriginLocal, admission.Origin)
241+ require.Equal(t, binding.Policy(), admission.Policy)
242+ require.Equal(t, ports.BrokerStreamControl, admission.Purpose)
243+ require.NoError(t, admission.Validate())
244+}
245+
246+// TestServerSupervisorRefusesLiarRegistration proves the supervisor's accepted
247+// origin reaches the listener's admission check: a client that lies about its
248+// locality - a local registration on a local carriage - is refused on its own
249+// stream and never delivered to the daemon.
250+func TestServerSupervisorRefusesLiarRegistration(t *testing.T) {
251+ binding := mustServerBinding(t)
252+ server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil)
253+ server.serve()
254+
255+ clientRaw, serverRaw := rawCarriagePair(t)
256+ server.adopt(context.Background(), serverRaw)
257+ carrier, result := awaitHandshakeBridge(t, clientRaw, muxEndpoint(binding.Identity(), binding.Policy(), "raw://liar"))
258+ require.NoError(t, server.awaitAdoption(t))
259+
260+ pump, err := NewPump(carrier, DirectionServer, result.Ceilings)
261+ require.NoError(t, err)
262+ t.Cleanup(func() { _ = pump.Close() })
263+ pump.Start(context.Background())
264+ logical, err := NewLogicalConnector(pump)
265+ require.NoError(t, err)
266+
267+ // A remote-shaped Open (a local carriage with a registration) contradicts
268+ // the accepted local origin: the listener refuses it rather than stamping a
269+ // provenance it did not provision.
270+ liar := p3cRemoteRequest(ports.BrokerConnectionID{1}, "dev@host:22", 1, binding.Policy())
271+ _, err = logical.Open(context.Background(), liar)
272+ require.Error(t, err)
273+ require.Zero(t, server.acceptedCount(), "a locality-contradicting open never reaches the accept stream")
274+}
275+
120276 // TestServerSupervisorServerLossIsolated proves one lost physical child never
121277 // stops the aggregate: a surviving child keeps accepting while the lost child
122278 // is reaped and removed.
M internal/adapters/daemonmux/unix_carriage_test.go +1 -1
@@ -82,7 +82,7 @@ func unixRawCarriagePair(t *testing.T) (RawFramedTransport, RawFramedTransport)
8282 // at path through ipc.DialMuxContext (which also verifies same-user peer
8383 // credentials).
8484 func unixDialer(path string) RawCarrierDialer {
85- return func(ctx context.Context, _ string) (RawFramedTransport, error) {
85+ return func(ctx context.Context, _ ports.BrokerDialTarget) (RawFramedTransport, error) {
8686 raw, err := ipc.DialMuxContext(ctx, path)
8787 if err != nil {
8888 return nil, err
D internal/adapters/quicnettest/reconnect_resume_test.go +0 -459
@@ -1,459 +0,0 @@
1-package quicnettest_test
2-
3-// This file proves the application-level QUIC reconnect path end to end with
4-// no fake carriage anywhere:
5-//
6-// - the real thin-client use case (internal/usecase/client.Run) drives the
7-// attach lifecycle, including its reconnect loop and resume-token handling;
8-// - the real sessionwire typed adapter wraps both ends;
9-// - two successive, genuinely separate QUIC connections run over the real
10-// internal/adapters/quic transport through the real UDP proxy;
11-// - the first connection is killed irreversibly (QUIC has no resumption here,
12-// 0-RTT is disabled) by closing the accepted server transport;
13-// - the resumed connection must carry a second preamble+handshake, a Hello
14-// with IntentResume, the same ClientID and the previous resume token, a
15-// Welcome with a renewed token, and a fresh full Output that the client
16-// accepts and ACKs.
17-//
18-// The daemon side is a scripted typed responder: it speaks only the real
19-// sessionwire protocol over the real QUIC carriage. No sshd, no netem, no root,
20-// and no fake QUIC transport is involved.
21-
22-import (
23- "bytes"
24- "context"
25- "errors"
26- "fmt"
27- "io"
28- "log/slog"
29- "net"
30- "sync"
31- "sync/atomic"
32- "testing"
33- "time"
34-
35- "github.com/bnema/vev/internal/adapters/clock"
36- quicadapter "github.com/bnema/vev/internal/adapters/quic"
37- "github.com/bnema/vev/internal/adapters/quicnettest"
38- "github.com/bnema/vev/internal/adapters/sessionwire"
39- "github.com/bnema/vev/internal/domain"
40- "github.com/bnema/vev/internal/ports"
41- "github.com/bnema/vev/internal/protocol"
42- "github.com/bnema/vev/internal/usecase/client"
43-)
44-
45-const (
46- reconnectProofTimeout = 30 * time.Second
47- reconnectSessionName = "work"
48-)
49-
50-func TestRealQUICApplicationReconnectResumesTypedSession(t *testing.T) {
51- cert, fingerprint, err := quicadapter.GenerateEphemeralCert()
52- if err != nil {
53- t.Fatalf("generate cert: %v", err)
54- }
55- listener, err := quicadapter.ListenConfig("127.0.0.1:0", cert, quicadapter.Config{}, 4)
56- if err != nil {
57- t.Fatalf("listen quic: %v", err)
58- }
59- t.Cleanup(func() { _ = listener.Close() })
60-
61- serverAddr, err := net.ResolveUDPAddr("udp", listener.Addr())
62- if err != nil {
63- t.Fatalf("resolve listener address %q: %v", listener.Addr(), err)
64- }
65- proxy, err := quicnettest.New(quicnettest.Config{
66- ServerAddr: serverAddr,
67- Seed: 20260915,
68- })
69- if err != nil {
70- t.Fatalf("new proxy: %v", err)
71- }
72- t.Cleanup(func() { _ = proxy.Close() })
73-
74- // The dialer is the production composition: a real QUIC dialer with the
75- // pinned certificate fingerprint, wrapped by the typed sessionwire dialer.
76- // The counter is observational only; every dial still opens a real QUIC
77- // connection through the proxy.
78- rawDialer := quicadapter.DialConfig(proxy.Addr().String(), "vev-reconnect", fingerprint, quicadapter.Config{}, 15*time.Second)
79- dialer := &reconnectCountingDialer{inner: sessionwire.NewClientDialer(rawDialer)}
80-
81- term := newReconnectProofTerminal()
82- t.Cleanup(term.unblock)
83-
84- deps := client.Dependencies{
85- Dialer: dialer,
86- Terminal: term,
87- Clock: clock.New(),
88- DisableCapabilityProbe: true,
89- Logger: slog.New(slog.DiscardHandler),
90- }
91-
92- obs := &reconnectProofObservations{}
93- var obsMu sync.Mutex
94- daemonDone := make(chan error, 1)
95- go func() { daemonDone <- serveReconnectProof(listener, obs, &obsMu) }()
96-
97- ctx, cancel := context.WithTimeout(context.Background(), reconnectProofTimeout)
98- defer cancel()
99- runResult := make(chan error, 1)
100- go func() {
101- runResult <- client.NewRunner(deps).Run(ctx, client.AttachRequest{
102- Intent: protocol.IntentAttach,
103- SessionName: reconnectSessionName,
104- })
105- }()
106-
107- select {
108- case err := <-daemonDone:
109- if err != nil {
110- t.Fatalf("scripted resume daemon: %v", err)
111- }
112- case <-time.After(reconnectProofTimeout):
113- t.Fatal("timed out waiting for the resumed session to complete")
114- }
115- select {
116- case err := <-runResult:
117- if err != nil {
118- t.Fatalf("client run: %v", err)
119- }
120- case <-time.After(reconnectProofTimeout):
121- t.Fatal("timed out waiting for the client run to return")
122- }
123-
124- obsMu.Lock()
125- got := *obs
126- obsMu.Unlock()
127-
128- if got.accepts != 2 {
129- t.Fatalf("listener accepted %d QUIC connections, want 2 (first must die irreversibly)", got.accepts)
130- }
131- if dialer.calls.Load() != 2 {
132- t.Fatalf("client dialed %d times, want exactly 2 real QUIC connections", dialer.calls.Load())
133- }
134-
135- // First connection: a fresh attach, no resume token.
136- if got.firstHello.Intent != protocol.IntentAttach {
137- t.Fatalf("first Hello.Intent = %d, want IntentAttach(%d)", got.firstHello.Intent, protocol.IntentAttach)
138- }
139- if got.firstHello.ResumeToken != 0 {
140- t.Fatalf("first Hello.ResumeToken = %d, want 0", got.firstHello.ResumeToken)
141- }
142- if got.firstHello.Name != reconnectSessionName {
143- t.Fatalf("first Hello.Name = %q, want %q", got.firstHello.Name, reconnectSessionName)
144- }
145-
146- // Second connection: the client must resume, not start over.
147- if got.secondHello.Intent != protocol.IntentResume {
148- t.Fatalf("second Hello.Intent = %d, want IntentResume(%d)", got.secondHello.Intent, protocol.IntentResume)
149- }
150- if got.secondHello.ClientID != got.firstHello.ClientID {
151- t.Fatalf("second Hello.ClientID = %x, want the first %x", got.secondHello.ClientID, got.firstHello.ClientID)
152- }
153- if got.secondHello.ResumeToken != got.firstWelcome.ResumeToken {
154- t.Fatalf("second Hello.ResumeToken = %d, want the first Welcome token %d", got.secondHello.ResumeToken, got.firstWelcome.ResumeToken)
155- }
156- if got.secondHello.Name != reconnectSessionName {
157- t.Fatalf("second Hello.Name = %q, want %q", got.secondHello.Name, reconnectSessionName)
158- }
159- if got.secondWelcome.ResumeToken == got.firstWelcome.ResumeToken || got.secondWelcome.ResumeToken == 0 {
160- t.Fatalf("second Welcome.ResumeToken = %d, want a renewal distinct from %d", got.secondWelcome.ResumeToken, got.firstWelcome.ResumeToken)
161- }
162-
163- // Fresh, resynchronised first Output: the resumed attachment starts from a
164- // full publication, the client accepts it (no reset request), and ACKs it.
165- if got.secondOutputResetRequested {
166- t.Fatal("client requested an output reset instead of accepting the fresh resumed Output")
167- }
168- if got.secondAck.Epoch != got.secondOutputEpoch || got.secondAck.State != got.secondOutputState {
169- t.Fatalf("second Ack = {%d,%d}, want {%d,%d}", got.secondAck.Epoch, got.secondAck.State, got.secondOutputEpoch, got.secondOutputState)
170- }
171- if !bytes.Contains([]byte(term.output()), []byte(reconnectResumedFrame)) {
172- t.Fatalf("client terminal never received the resumed fresh Output %q", reconnectResumedFrame)
173- }
174-
175- stats := waitForAccounting(t, proxy, 10*time.Second)
176- if stats.ToServer.Received == 0 || stats.ToClient.Received == 0 {
177- t.Fatalf("proxy carried no QUIC traffic: %s", stats)
178- }
179- if stats.ToServer.OverflowDrops != 0 || stats.ToClient.OverflowDrops != 0 {
180- t.Fatalf("bounded proxy queue overflowed: %s", stats)
181- }
182- if stats.ToServer.DeliveryErrors != 0 || stats.ToClient.DeliveryErrors != 0 {
183- t.Fatalf("proxy delivery failed: %s", stats)
184- }
185- if stats.StrayPackets != 0 {
186- t.Fatalf("proxy observed %d stray packets, want 0", stats.StrayPackets)
187- }
188-}
189-
190-const (
191- reconnectInitialToken = uint64(0x1111)
192- reconnectRenewedToken = uint64(0x2222)
193- reconnectFirstFrame = "first frame"
194- reconnectResumedFrame = "resumed frame"
195-)
196-
197-// reconnectProofObservations is the daemon-side record, written by the daemon
198-// goroutine and read by the test goroutine after daemonDone closes.
199-type reconnectProofObservations struct {
200- accepts int
201-
202- firstHello protocol.Hello
203- firstWelcome protocol.Welcome
204- firstAck protocol.Ack
205-
206- secondHello protocol.Hello
207- secondWelcome protocol.Welcome
208- secondAck protocol.Ack
209- // secondOutputEpoch/State are the boundary of the fresh resumed Output the
210- // daemon sent; the Ack must match them.
211- secondOutputEpoch uint64
212- secondOutputState uint64
213- secondOutputResetRequested bool
214-}
215-
216-// serveReconnectProof drives two successive real QUIC connections as a typed
217-// sessionwire server: a first attach that publishes output and is then killed
218-// irreversibly, and a second resume that must carry the old token.
219-func serveReconnectProof(listener *quicadapter.Listener, obs *reconnectProofObservations, mu *sync.Mutex) error {
220- // --- First connection: fresh attach, then irreversible death. ---
221- firstRaw, err := listener.Accept()
222- if err != nil {
223- return fmt.Errorf("accept first QUIC connection: %w", err)
224- }
225- first := sessionwire.NewServerConnection(firstRaw)
226- firstHello, err := receiveProofHello(first)
227- if err != nil {
228- _ = first.Close()
229- return fmt.Errorf("first connection: %w", err)
230- }
231- mu.Lock()
232- obs.accepts++
233- obs.firstHello = firstHello
234- mu.Unlock()
235-
236- firstWelcome := protocol.Welcome{
237- SessionID: "work-session",
238- SessionName: reconnectSessionName,
239- ResumeToken: reconnectInitialToken,
240- Capabilities: protocol.CapabilityResume,
241- }
242- if err := first.SendServer(firstWelcome); err != nil {
243- _ = first.Close()
244- return fmt.Errorf("first connection: send welcome: %w", err)
245- }
246- if err := first.SendOutput(reconnectProofOutput(1, []byte(reconnectFirstFrame))); err != nil {
247- _ = first.Close()
248- return fmt.Errorf("first connection: send output: %w", err)
249- }
250- firstAck, requestedReset, err := awaitProofAck(first)
251- if err != nil {
252- _ = first.Close()
253- return fmt.Errorf("first connection: %w", err)
254- }
255- mu.Lock()
256- obs.firstWelcome = firstWelcome
257- obs.firstAck = firstAck
258- mu.Unlock()
259- if requestedReset {
260- _ = first.Close()
261- return errors.New("first connection: client requested output reset for a fresh publication")
262- }
263- // Irreversible death: QUIC resumption/0-RTT is disabled, so closing the
264- // accepted connection can never be undone by a later packet.
265- if err := first.Close(); err != nil {
266- return fmt.Errorf("first connection: irreversible close: %w", err)
267- }
268-
269- // --- Second connection: resume with the previous token. ---
270- secondRaw, err := listener.Accept()
271- if err != nil {
272- return fmt.Errorf("accept second QUIC connection: %w", err)
273- }
274- second := sessionwire.NewServerConnection(secondRaw)
275- secondHello, err := receiveProofHello(second)
276- if err != nil {
277- _ = second.Close()
278- return fmt.Errorf("second connection: %w", err)
279- }
280-
281- secondWelcome := protocol.Welcome{
282- SessionID: "work-session",
283- SessionName: reconnectSessionName,
284- ResumeToken: reconnectRenewedToken,
285- Capabilities: protocol.CapabilityResume,
286- }
287- if err := second.SendServer(secondWelcome); err != nil {
288- _ = second.Close()
289- return fmt.Errorf("second connection: send welcome: %w", err)
290- }
291- resumed := reconnectProofOutput(7, []byte(reconnectResumedFrame))
292- if err := second.SendOutput(resumed); err != nil {
293- _ = second.Close()
294- return fmt.Errorf("second connection: send resumed output: %w", err)
295- }
296- secondAck, requestedReset, err := awaitProofAck(second)
297- if err != nil {
298- _ = second.Close()
299- return fmt.Errorf("second connection: %w", err)
300- }
301-
302- mu.Lock()
303- obs.accepts++
304- obs.secondHello = secondHello
305- obs.secondWelcome = secondWelcome
306- obs.secondAck = secondAck
307- obs.secondOutputEpoch = resumed.Epoch
308- obs.secondOutputState = resumed.New
309- obs.secondOutputResetRequested = requestedReset
310- mu.Unlock()
311-
312- // End the run cleanly so the client use case returns nil. The connection
313- // must stay open until the client has consumed the Detached and closed its
314- // own side: closing first can drop the frame, which the client would read as
315- // an unrecoverable link loss and answer with yet another resume dial.
316- if err := second.SendServer(protocol.Detached{Reason: protocol.ReasonDetach}); err != nil {
317- _ = second.Close()
318- return fmt.Errorf("second connection: send detached: %w", err)
319- }
320- for {
321- if _, err := second.ReceiveClient(); err != nil {
322- break
323- }
324- }
325- return second.Close()
326-}
327-
328-func receiveProofHello(conn ports.ServerConnection) (protocol.Hello, error) {
329- message, err := conn.ReceiveClient()
330- if err != nil {
331- return protocol.Hello{}, fmt.Errorf("receive hello: %w", err)
332- }
333- hello, ok := message.(protocol.Hello)
334- if !ok {
335- return protocol.Hello{}, fmt.Errorf("first message is %T, want protocol.Hello", message)
336- }
337- return hello, nil
338-}
339-
340-// awaitProofAck reads client messages until the first Ack, reporting whether a
341-// reset request was seen first. The bounded skip count keeps a misbehaving peer
342-// from hiding the Ack behind an unbounded control stream.
343-func awaitProofAck(conn ports.ServerConnection) (ack protocol.Ack, resetRequested bool, err error) {
344- for skipped := 0; skipped < 32; skipped++ {
345- message, recvErr := conn.ReceiveClient()
346- if recvErr != nil {
347- return protocol.Ack{}, false, fmt.Errorf("receive ack: %w", recvErr)
348- }
349- switch typed := message.(type) {
350- case protocol.Ack:
351- return typed, false, nil
352- case protocol.OutputResetRequest:
353- return protocol.Ack{}, true, nil
354- }
355- }
356- return protocol.Ack{}, false, errors.New("no ack within 32 client messages")
357-}
358-
359-// reconnectProofOutput builds a valid fresh full publication for the scripted
360-// daemon. Its data is unique per connection so the client terminal proves which
361-// frame was applied.
362-func reconnectProofOutput(publication uint64, data []byte) protocol.Output {
363- return protocol.Output{
364- Epoch: 1,
365- Base: 0,
366- New: 1,
367- ViewRevision: 1,
368- Size: domain.Size{Cols: 80, Rows: 24},
369- Full: true,
370- Context: &protocol.ViewContext{
371- Publication: publication,
372- Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{
373- LifecycleID: domain.SessionLifecycleID{
374- 0x52, 0x45, 0x53, 0x55, 0x4d, 0x45, 0x00, 0x01,
375- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
376- },
377- SessionName: reconnectSessionName,
378- }},
379- TabID: domain.TabStableID("tab-1"),
380- FocusedPaneID: domain.PaneStableID("pane-1"),
381- },
382- Data: data,
383- }
384-}
385-
386-// reconnectCountingDialer counts real dials without replacing the carriage.
387-type reconnectCountingDialer struct {
388- inner ports.ClientDialer
389- calls atomic.Int64
390-}
391-
392-func (d *reconnectCountingDialer) Dial(ctx context.Context) (ports.ClientConnection, error) {
393- d.calls.Add(1)
394- return d.inner.Dial(ctx)
395-}
396-
397-// reconnectProofTerminal is a headless ports.Terminal. Its input never
398-// produces bytes, so the client's input pump stays parked for the run.
399-type reconnectProofTerminal struct {
400- in *reconnectProofInput
401- mu sync.Mutex
402- out bytes.Buffer
403- resize chan domain.Geometry
404-}
405-
406-func newReconnectProofTerminal() *reconnectProofTerminal {
407- return &reconnectProofTerminal{
408- in: &reconnectProofInput{closed: make(chan struct{})},
409- resize: make(chan domain.Geometry),
410- }
411-}
412-
413-func (t *reconnectProofTerminal) EnterRaw() (func() error, error) {
414- return func() error { return nil }, nil
415-}
416-
417-func (t *reconnectProofTerminal) Geometry() (domain.Geometry, error) {
418- return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
419-}
420-
421-func (t *reconnectProofTerminal) ResizeEvents() <-chan domain.Geometry { return t.resize }
422-func (t *reconnectProofTerminal) In() io.Reader { return t.in }
423-func (t *reconnectProofTerminal) Out() io.Writer {
424- return reconnectProofWriter{mu: &t.mu, buf: &t.out}
425-}
426-func (t *reconnectProofTerminal) Flush() error { return nil }
427-
428-func (t *reconnectProofTerminal) output() string {
429- t.mu.Lock()
430- defer t.mu.Unlock()
431- return t.out.String()
432-}
433-
434-func (t *reconnectProofTerminal) unblock() { t.in.unblock() }
435-
436-type reconnectProofWriter struct {
437- mu *sync.Mutex
438- buf *bytes.Buffer
439-}
440-
441-func (w reconnectProofWriter) Write(p []byte) (int, error) {
442- w.mu.Lock()
443- defer w.mu.Unlock()
444- return w.buf.Write(p)
445-}
446-
447-type reconnectProofInput struct {
448- closed chan struct{}
449- once sync.Once
450-}
451-
452-func (r *reconnectProofInput) Read([]byte) (int, error) {
453- <-r.closed
454- return 0, io.EOF
455-}
456-
457-func (r *reconnectProofInput) unblock() {
458- r.once.Do(func() { close(r.closed) })
459-}
D internal/adapters/remote/catalog.go +0 -459
@@ -1,459 +0,0 @@
1-package remote
2-
3-import (
4- "bytes"
5- "context"
6- "encoding/json"
7- "errors"
8- "fmt"
9- "io"
10- "log/slog"
11- "os"
12- "os/exec"
13- "strings"
14- "time"
15- "unicode"
16- "unicode/utf8"
17-
18- "github.com/bnema/vev/internal/adapters/sshstdio"
19- "github.com/bnema/vev/internal/domain"
20- appports "github.com/bnema/vev/internal/ports"
21- "github.com/bnema/vev/internal/protocol"
22- "github.com/bnema/vev/internal/protocol/catalogue"
23-)
24-
25-const (
26- maxCatalogBytes = 1 << 20
27- maxCatalogDiagnosticBytes = 4 << 10
28- catalogCommandTimeout = 10 * time.Second
29- catalogSSHConnectTimeout = 5 * time.Second
30- catalogCommandWaitDelay = 500 * time.Millisecond
31-)
32-
33-var (
34- errCatalogDecode = errors.New("remote catalog: invalid JSON")
35- errCatalogTrailing = errors.New("remote catalog: trailing bytes after JSON")
36- errCatalogRequired = errors.New("remote catalog: missing required fields")
37- errCatalogSSH = errors.New("remote catalog: ssh command failed")
38- errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit")
39-)
40-
41-// sshClientTrustMarkers are OpenSSH-client-originated diagnostics for host-key
42-// trust failures. They are only consulted when the ssh client itself exited
43-// 255, which separates client-side failures from remote command output.
44-var sshClientTrustMarkers = []string{
45- "Host key verification failed.",
46- "REMOTE HOST IDENTIFICATION HAS CHANGED",
47- "Host key for ",
48- "No host key is known ",
49- "No ECDSA host key is known ",
50-}
51-
52-// sshClientAuthMarkers are OpenSSH-client diagnostics for explicit
53-// authentication failures. Unlike host-key text, these phrases can also be
54-// emitted by a remote command that exits 255 (ssh propagates the remote
55-// status), so they only classify when ssh-client framing proves client
56-// provenance: an `ssh:` line prefix or a `host:` label prefix on the same
57-// line. Bare matches stay generic transport errors.
58-var sshClientAuthMarkers = []string{
59- "Permission denied ",
60- "Permission denied (",
61- "Too many authentication failures",
62- "Authentication failed",
63-}
64-
65-// sshClientTimeoutMarkers are OpenSSH-client diagnostics for bounded
66-// connect/command timeouts. As with authentication text, they require
67-// ssh-client framing; unframed matches stay generic transport errors.
68-var sshClientTimeoutMarkers = []string{
69- "Connection timed out",
70- "timed out",
71- "Timeout, server not responding",
72-}
73-
74-// ClassifyCatalogError maps an observation failure to its sanitized failure
75-// kind without leaking raw stderr, keys, endpoints or terminal contents.
76-// Unrecognized SSH errors stay generic transport errors: authentication is
77-// never inferred from arbitrary remote stderr, only from explicit ssh-client
78-// diagnostics on ssh client exit 255.
79-func ClassifyCatalogError(err error) domain.RemoteFailureKind {
80- if err == nil {
81- return domain.RemoteFailureNone
82- }
83- if errors.Is(err, context.DeadlineExceeded) {
84- return domain.RemoteFailureTimeout
85- }
86- var mismatch *catalogue.RemoteCatalogVersionMismatchError
87- if errors.As(err, &mismatch) {
88- return domain.RemoteFailureIncompatible
89- }
90- if errors.Is(err, errCatalogTooLarge) ||
91- errors.Is(err, errCatalogDecode) ||
92- errors.Is(err, errCatalogTrailing) ||
93- errors.Is(err, errCatalogRequired) ||
94- errors.Is(err, catalogue.ErrInvalidRemoteCatalog) {
95- return domain.RemoteFailureInvalidResponse
96- }
97- if errors.Is(err, errCatalogSSH) && sshClientExit(err) {
98- text := err.Error()
99- for _, marker := range sshClientTrustMarkers {
100- if strings.Contains(text, marker) {
101- return domain.RemoteFailureTrust
102- }
103- }
104- for _, marker := range sshClientAuthMarkers {
105- if hasClientFramedMarker(text, marker) {
106- return domain.RemoteFailureAuthentication
107- }
108- }
109- for _, marker := range sshClientTimeoutMarkers {
110- if hasClientFramedMarker(text, marker) {
111- return domain.RemoteFailureTimeout
112- }
113- }
114- }
115- if timeoutError(err) {
116- return domain.RemoteFailureTimeout
117- }
118- return domain.RemoteFailureTransport
119-}
120-
121-// hasClientFramedMarker reports whether marker appears with ssh-client
122-// framing: on a line starting with `ssh:`, or after a `host:` label prefix
123-// on the same line (the `destination: message` form ssh uses for its own
124-// errors). Only the stderr tail past the client's exit status is examined
125-// so the wrapper's own colons never count as framing. A remote command
126-// exiting 255 can emit the same phrases without framing, so unframed
127-// matches prove nothing and stay transport errors.
128-func hasClientFramedMarker(text, marker string) bool {
129- diag := text
130- if _, after, ok := strings.Cut(text, "exit status 255: "); ok {
131- diag = after
132- }
133- for _, line := range strings.Split(diag, "\n") {
134- if !strings.Contains(line, marker) {
135- continue
136- }
137- if strings.HasPrefix(line, "ssh:") {
138- return true
139- }
140- if label, _, ok := strings.Cut(line, ": "); ok && !strings.ContainsAny(label, " \t:") {
141- return true
142- }
143- }
144- return false
145-}
146-
147-// sshClientExit reports whether the ssh client itself failed (exit 255),
148-// separating client-side transport/auth/trust failures from remote command
149-// output carried in the same wrapped error.
150-func sshClientExit(err error) bool {
151- var exit *exec.ExitError
152- if !errors.As(err, &exit) {
153- return false
154- }
155- return exit.ExitCode() == 255
156-}
157-
158-func timeoutError(err error) bool {
159- var timeout interface{ Timeout() bool }
160- if errors.As(err, &timeout) && timeout.Timeout() {
161- return true
162- }
163- return errors.Is(err, os.ErrDeadlineExceeded)
164-}
165-
166-// CatalogClient fetches remote session catalogs over SSH.
167-type CatalogClient struct {
168- command func(ctx context.Context, name string, args ...string) *exec.Cmd
169- // timeout bounds each List when > 0; otherwise catalogCommandTimeout is used.
170- timeout time.Duration
171-}
172-
173-var _ appports.RemoteCatalogClient = (*CatalogClient)(nil)
174-
175-// NewCatalogClient returns a RemoteCatalogClient that shells out to ssh.
176-func NewCatalogClient() appports.RemoteCatalogClient {
177- return &CatalogClient{command: exec.CommandContext}
178-}
179-
180-func (c *CatalogClient) listTimeout() time.Duration {
181- if c != nil && c.timeout > 0 {
182- return c.timeout
183- }
184- return catalogCommandTimeout
185-}
186-
187-// List runs a non-interactive `ssh` observation of `vev cmd remote-catalog
188-// --json` and decodes exactly one versioned catalog envelope from stdout.
189-// The observation allocates no TTY, invokes no askpass helper, and executes
190-// no host strings as shell fragments. Host authentication and trust remain
191-// owned by effective OpenSSH policy. List always applies a bounded command
192-// timeout derived from ctx so a caller with no deadline cannot hang
193-// indefinitely, while still honoring caller cancellation; owned SSH
194-// processes are killed and reaped on cancellation with a bounded wait.
195-func (c *CatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) {
196- if err := ctx.Err(); err != nil {
197- return catalogue.RemoteCatalog{}, err
198- }
199- if err := domain.ValidateRemoteHostTarget(target); err != nil {
200- slog.Debug("remote catalog rejected target", "target", target, "err", err)
201- return catalogue.RemoteCatalog{}, err
202- }
203-
204- runCtx, cancel := context.WithTimeout(ctx, c.listTimeout())
205- defer cancel()
206-
207- command := c.command
208- if command == nil {
209- command = exec.CommandContext
210- }
211- spec := sshstdio.BuildCommandForObservation(target, catalogSSHConnectTimeout, "vev", "cmd", "remote-catalog", "--json")
212- cmd := command(runCtx, spec.Path, spec.Args...)
213- // Stdin stays detached and askpass is explicitly disabled. This preserves
214- // host-specific authentication policy without allowing GUI prompt helpers.
215- cmd.Env = append(cmd.Environ(), "SSH_ASKPASS_REQUIRE=never")
216- stdout := boundedBuffer{limit: maxCatalogBytes}
217- stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes}
218- cmd.Stdout = &stdout
219- cmd.Stderr = &stderr
220- cmd.WaitDelay = catalogCommandWaitDelay
221- err := cmd.Run()
222- if ctxErr := runCtx.Err(); ctxErr != nil {
223- return catalogue.RemoteCatalog{}, ctxErr
224- }
225- if err != nil {
226- if stdout.overflow || stderr.overflow {
227- slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
228- return catalogue.RemoteCatalog{}, errCatalogTooLarge
229- }
230- stderrText := sanitizeCatalogDiagnostic(string(stderr.Bytes()))
231- slog.Debug("remote catalog ssh failed", "target", target, "err", err, "stderr", stderrText)
232- if stderrText != "" {
233- return catalogue.RemoteCatalog{}, fmt.Errorf("%w: %w: %s", errCatalogSSH, err, stderrText)
234- }
235- return catalogue.RemoteCatalog{}, fmt.Errorf("%w: %w", errCatalogSSH, err)
236- }
237- if stdout.overflow || stderr.overflow {
238- slog.Debug("remote catalog output too large", "target", target, "stdout_limit", maxCatalogBytes, "stderr_limit", maxCatalogDiagnosticBytes)
239- return catalogue.RemoteCatalog{}, errCatalogTooLarge
240- }
241-
242- catalog, err := decodeRemoteCatalog(stdout.Bytes())
243- if err != nil {
244- slog.Debug("remote catalog decode failed", "target", target, "err", err)
245- return catalogue.RemoteCatalog{}, err
246- }
247- return catalog, nil
248-}
249-
250-type catalogVersionEnvelope struct {
251- ProtocolVersion *uint16 `json:"protocol_version"`
252- SchemaVersion *uint16 `json:"schema_version"`
253- Sessions json.RawMessage `json:"sessions"`
254-}
255-
256-type catalogEnvelope struct {
257- ProtocolVersion *uint16 `json:"protocol_version"`
258- SchemaVersion *uint16 `json:"schema_version"`
259- Sessions *[]catalogSessionEnvelope `json:"sessions"`
260-}
261-
262-type catalogSessionEnvelope struct {
263- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
264- Name *string `json:"name"`
265- State *catalogue.RemoteCatalogSessionState `json:"state"`
266- Ephemeral *bool `json:"ephemeral"`
267- Tabs *[]catalogTabEnvelope `json:"tabs"`
268- Attached *bool `json:"attached"`
269- LastUsedSeq uint64 `json:"last_used_seq,omitempty"`
270- ActiveTabID string `json:"active_tab_id,omitempty"`
271- Reason string `json:"reason,omitempty"`
272-}
273-
274-type catalogTabEnvelope struct {
275- ID *string `json:"id"`
276- Index *uint16 `json:"index"`
277- Name *string `json:"name"`
278- Detail string `json:"detail,omitempty"`
279- Attention bool `json:"attention,omitempty"`
280-}
281-
282-func decodeRemoteCatalog(raw []byte) (catalogue.RemoteCatalog, error) {
283- var version catalogVersionEnvelope
284- if err := decodeCatalogEnvelope(raw, false, &version); err != nil {
285- return catalogue.RemoteCatalog{}, err
286- }
287- if version.ProtocolVersion == nil {
288- return catalogue.RemoteCatalog{}, errCatalogRequired
289- }
290- if version.SchemaVersion == nil {
291- return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{
292- Got: 0,
293- Want: catalogue.RemoteCatalogSchemaVersion,
294- Kind: "catalog",
295- }
296- }
297- if *version.ProtocolVersion != protocol.Version {
298- return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{
299- Got: *version.ProtocolVersion,
300- Want: protocol.Version,
301- Kind: "protocol",
302- }
303- }
304- if *version.SchemaVersion != catalogue.RemoteCatalogSchemaVersion {
305- return catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{
306- Got: *version.SchemaVersion,
307- Want: catalogue.RemoteCatalogSchemaVersion,
308- Kind: "catalog",
309- }
310- }
311-
312- var envelope catalogEnvelope
313- if err := decodeCatalogEnvelope(raw, true, &envelope); err != nil {
314- return catalogue.RemoteCatalog{}, err
315- }
316- if envelope.ProtocolVersion == nil || envelope.SchemaVersion == nil || envelope.Sessions == nil {
317- return catalogue.RemoteCatalog{}, errCatalogRequired
318- }
319- sessions := make([]catalogue.RemoteCatalogSession, 0, len(*envelope.Sessions))
320- for _, session := range *envelope.Sessions {
321- if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Tabs == nil || session.Attached == nil {
322- return catalogue.RemoteCatalog{}, errCatalogRequired
323- }
324- tabs := make([]catalogue.RemoteCatalogTab, 0, len(*session.Tabs))
325- for _, tab := range *session.Tabs {
326- if tab.ID == nil || tab.Index == nil || tab.Name == nil {
327- return catalogue.RemoteCatalog{}, errCatalogRequired
328- }
329- tabs = append(tabs, catalogue.RemoteCatalogTab{
330- ID: *tab.ID, Index: *tab.Index, Name: *tab.Name,
331- Detail: tab.Detail, Attention: tab.Attention,
332- })
333- }
334- sessions = append(sessions, catalogue.RemoteCatalogSession{
335- LifecycleID: *session.LifecycleID, Name: *session.Name, State: *session.State,
336- Ephemeral: *session.Ephemeral, Tabs: tabs, Attached: *session.Attached,
337- LastUsedSeq: session.LastUsedSeq, ActiveTabID: session.ActiveTabID, Reason: session.Reason,
338- })
339- }
340- catalog := catalogue.RemoteCatalog{
341- ProtocolVersion: *envelope.ProtocolVersion,
342- SchemaVersion: *envelope.SchemaVersion,
343- Sessions: sessions,
344- }
345- if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
346- return catalogue.RemoteCatalog{}, err
347- }
348- return catalog, nil
349-}
350-
351-func decodeCatalogEnvelope(raw []byte, strict bool, dst any) error {
352- dec := json.NewDecoder(bytes.NewReader(raw))
353- if strict {
354- dec.DisallowUnknownFields()
355- }
356- if err := dec.Decode(dst); err != nil {
357- return fmt.Errorf("%w: %w", errCatalogDecode, err)
358- }
359- return rejectTrailingJSON(dec)
360-}
361-
362-func rejectTrailingJSON(dec *json.Decoder) error {
363- var extra json.RawMessage
364- err := dec.Decode(&extra)
365- if errors.Is(err, io.EOF) {
366- return nil
367- }
368- if err == nil {
369- return errCatalogTrailing
370- }
371- return fmt.Errorf("%w: %w", errCatalogTrailing, err)
372-}
373-
374-// sanitizeCatalogDiagnostic strips terminal escapes and control bytes while
375-// preserving useful printable diagnostic text for errors and logs.
376-func sanitizeCatalogDiagnostic(raw string) string {
377- var b strings.Builder
378- b.Grow(len(raw))
379- for i := 0; i < len(raw); {
380- if raw[i] == 0x1b {
381- i = skipTerminalEscape(raw, i+1)
382- continue
383- }
384- r, size := utf8.DecodeRuneInString(raw[i:])
385- if r == utf8.RuneError && size == 1 {
386- i++
387- continue
388- }
389- if unicode.IsPrint(r) || r == '\t' || r == '\n' {
390- b.WriteRune(r)
391- }
392- i += size
393- }
394- return strings.TrimSpace(b.String())
395-}
396-
397-func skipTerminalEscape(s string, i int) int {
398- if i >= len(s) {
399- return i
400- }
401- switch s[i] {
402- case '[': // CSI: ESC [ ... final byte in 0x40-0x7E
403- i++
404- for i < len(s) {
405- ch := s[i]
406- i++
407- if ch >= 0x40 && ch <= 0x7e {
408- break
409- }
410- }
411- return i
412- case ']': // OSC: ESC ] ... BEL or ST (ESC \)
413- i++
414- for i < len(s) {
415- if s[i] == 0x07 {
416- return i + 1
417- }
418- if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' {
419- return i + 2
420- }
421- i++
422- }
423- return i
424- default:
425- // Two-byte Fe escape or unknown: drop ESC and the next byte.
426- return i + 1
427- }
428-}
429-
430-// boundedBuffer captures stream output up to an explicit byte limit and records overflow.
431-type boundedBuffer struct {
432- buf bytes.Buffer
433- limit int
434- overflow bool
435-}
436-
437-func (b *boundedBuffer) Write(p []byte) (int, error) {
438- if b.overflow {
439- return len(p), nil
440- }
441- remaining := b.limit - b.buf.Len()
442- if remaining <= 0 {
443- b.overflow = true
444- return len(p), nil
445- }
446- if len(p) <= remaining {
447- return b.buf.Write(p)
448- }
449- n, err := b.buf.Write(p[:remaining])
450- b.overflow = true
451- if err != nil {
452- return n, err
453- }
454- return len(p), nil
455-}
456-
457-func (b *boundedBuffer) Bytes() []byte {
458- return b.buf.Bytes()
459-}
D internal/adapters/remote/catalog_cache.go +0 -352
@@ -1,352 +0,0 @@
1-package remote
2-
3-import (
4- "bytes"
5- "encoding/hex"
6- "encoding/json"
7- "errors"
8- "fmt"
9- "os"
10- "path/filepath"
11- "sort"
12- "time"
13- "unicode/utf8"
14-
15- "github.com/bnema/vev/internal/domain"
16- appports "github.com/bnema/vev/internal/ports"
17- "github.com/bnema/vev/internal/protocol/catalogue"
18-)
19-
20-const (
21- catalogCacheFileName = "remote-catalog-cache.json"
22- catalogCacheExactFileVersion = 2
23- catalogCacheLegacyFileVersion = 3
24- catalogCacheFileVersion = 4
25-)
26-
27-// CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.
28-func CatalogCachePath(stateDir string) string { return filepath.Join(stateDir, catalogCacheFileName) }
29-
30-type catalogCacheFile struct {
31- Version *int `json:"version"`
32- Hosts *[]catalogCacheHost `json:"hosts"`
33-}
34-
35-type catalogCacheVersion struct {
36- Version *int `json:"version"`
37-}
38-
39-type catalogCacheHost struct {
40- Target *string `json:"target"`
41- FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
42- Incarnation *string `json:"incarnation,omitempty"`
43- Sessions *[]catalogCacheSession `json:"sessions"`
44-}
45-
46-type catalogCacheSession struct {
47- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"`
48- Name *string `json:"name"`
49- State *catalogue.RemoteCatalogSessionState `json:"state"`
50- Ephemeral *bool `json:"ephemeral"`
51- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
52- Tabs *[]catalogue.RemoteCatalogTab `json:"tabs"`
53- ActiveTabID *string `json:"active_tab_id,omitempty"`
54- Attached *bool `json:"attached"`
55-}
56-
57-type catalogCacheFileV2 struct {
58- Version *int `json:"version"`
59- Hosts *[]catalogCacheHostV2 `json:"hosts"`
60-}
61-
62-type catalogCacheHostV2 struct {
63- Target *string `json:"target"`
64- FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
65- Sessions *[]catalogCacheSessionV2 `json:"sessions"`
66-}
67-
68-type catalogCacheSessionV2 struct {
69- LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"`
70- Name *string `json:"name"`
71- State *catalogue.RemoteCatalogSessionState `json:"state"`
72- Ephemeral *bool `json:"ephemeral"`
73- LastUsedSeq *uint64 `json:"last_used_seq,omitempty"`
74- Tabs *uint16 `json:"tabs,omitempty"`
75- TabList *[]catalogue.RemoteCatalogTab `json:"tab_list,omitempty"`
76- ActiveTabID *string `json:"active_tab_id,omitempty"`
77- Attached *bool `json:"attached"`
78-}
79-
80-type fileCatalogCache struct {
81- path string
82-}
83-
84-// NewFileCatalogCache returns a durable remote catalog cache backed by path.
85-func NewFileCatalogCache(path string) appports.RemoteCatalogCache {
86- return &fileCatalogCache{path: path}
87-}
88-
89-var _ appports.RemoteCatalogCache = (*fileCatalogCache)(nil)
90-
91-// Load reads and validates one complete cache snapshot. Exact v2 tab-list
92-// snapshots are upgraded in memory; ambiguous count-only v2 data is rejected.
93-// A missing cache is an empty snapshot, and Load never changes cache data.
94-func (c *fileCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
95- raw, err := os.ReadFile(c.path)
96- if errors.Is(err, os.ErrNotExist) {
97- return []catalogue.RemoteCatalogCacheEntry{}, nil
98- }
99- if err != nil {
100- return nil, err
101- }
102- if !utf8.Valid(raw) {
103- return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
104- }
105-
106- var version catalogCacheVersion
107- if err := decodeCatalogCacheFile(raw, false, &version); err != nil {
108- return nil, err
109- }
110- if version.Version == nil {
111- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version")
112- }
113-
114- var file catalogCacheFile
115- requireIncarnation := false
116- switch *version.Version {
117- case catalogCacheFileVersion:
118- requireIncarnation = true
119- if err := decodeCatalogCacheFile(raw, true, &file); err != nil {
120- return nil, err
121- }
122- case catalogCacheLegacyFileVersion:
123- if err := decodeCatalogCacheFile(raw, true, &file); err != nil {
124- return nil, err
125- }
126- case catalogCacheExactFileVersion:
127- var legacy catalogCacheFileV2
128- if err := decodeCatalogCacheFile(raw, true, &legacy); err != nil {
129- return nil, err
130- }
131- migrated, err := migrateExactCatalogCacheV2(legacy)
132- if err != nil {
133- return nil, err
134- }
135- file = migrated
136- default:
137- return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *version.Version)
138- }
139- if file.Hosts == nil {
140- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts")
141- }
142-
143- entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(*file.Hosts))
144- for _, host := range *file.Hosts {
145- if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil {
146- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
147- }
148- if !utf8.ValidString(*host.Target) {
149- return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
150- }
151- if *host.FetchedAtUnixNano <= 0 {
152- return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time")
153- }
154- entry := catalogue.RemoteCatalogCacheEntry{
155- Host: *host.Target,
156- FetchedAt: time.Unix(0, *host.FetchedAtUnixNano),
157- Sessions: make([]catalogue.RemoteCatalogSession, 0, len(*host.Sessions)),
158- }
159- if host.Incarnation != nil {
160- raw, err := hex.DecodeString(*host.Incarnation)
161- if err != nil || len(raw) != 16 {
162- return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid incarnation for %q", *host.Target)
163- }
164- copy(entry.Incarnation[:], raw)
165- } else if requireIncarnation {
166- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing incarnation for %q", *host.Target)
167- }
168- // A zero incarnation is unbound advisory data: it loads but must
169- // never seed a registration.
170- for _, session := range *host.Sessions {
171- if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil {
172- return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")
173- }
174- if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) {
175- return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8")
176- }
177- tabs := make([]catalogue.RemoteCatalogTab, len(*session.Tabs))
178- copy(tabs, *session.Tabs)
179- decoded := catalogue.RemoteCatalogSession{
180- LifecycleID: *session.LifecycleID,
181- Name: *session.Name,
182- State: *session.State,
183- Ephemeral: *session.Ephemeral,
184- Tabs: tabs,
185- Attached: *session.Attached,
186- }
187- if session.LastUsedSeq != nil {
188- decoded.LastUsedSeq = *session.LastUsedSeq
189- }
190- if session.ActiveTabID != nil {
191- decoded.ActiveTabID = *session.ActiveTabID
192- }
193- entry.Sessions = append(entry.Sessions, decoded)
194- }
195- entries = append(entries, entry)
196- }
197- normalized, err := normalizeCatalogCacheEntries(entries)
198- if err != nil {
199- return nil, err
200- }
201- return normalized, nil
202-}
203-
204-func decodeCatalogCacheFile(raw []byte, strict bool, dst any) error {
205- dec := json.NewDecoder(bytes.NewReader(raw))
206- if strict {
207- dec.DisallowUnknownFields()
208- }
209- if err := dec.Decode(dst); err != nil {
210- return fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
211- }
212- if err := rejectTrailingJSON(dec); err != nil {
213- return fmt.Errorf("remote catalog cache: malformed cache file: %w", err)
214- }
215- return nil
216-}
217-
218-func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, error) {
219- if legacy.Version == nil || *legacy.Version != catalogCacheExactFileVersion || legacy.Hosts == nil {
220- return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed version 2 cache file")
221- }
222- hosts := make([]catalogCacheHost, 0, len(*legacy.Hosts))
223- for _, host := range *legacy.Hosts {
224- if host.Sessions == nil {
225- return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields")
226- }
227- sessions := make([]catalogCacheSession, 0, len(*host.Sessions))
228- for _, session := range *host.Sessions {
229- if session.Tabs != nil || session.TabList == nil {
230- return catalogCacheFile{}, fmt.Errorf("remote catalog cache: unsupported ambiguous version 2 tab count")
231- }
232- sessions = append(sessions, catalogCacheSession{
233- LifecycleID: session.LifecycleID,
234- Name: session.Name,
235- State: session.State,
236- Ephemeral: session.Ephemeral,
237- LastUsedSeq: session.LastUsedSeq,
238- Tabs: session.TabList,
239- ActiveTabID: session.ActiveTabID,
240- Attached: session.Attached,
241- })
242- }
243- hosts = append(hosts, catalogCacheHost{
244- Target: host.Target, FetchedAtUnixNano: host.FetchedAtUnixNano, Sessions: &sessions,
245- })
246- }
247- version := catalogCacheFileVersion
248- return catalogCacheFile{Version: &version, Hosts: &hosts}, nil
249-}
250-
251-// Store atomically replaces the cache with a complete normalized snapshot.
252-func (c *fileCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
253- normalized, err := normalizeCatalogCacheEntries(entries)
254- if err != nil {
255- return err
256- }
257- hosts := make([]catalogCacheHost, 0, len(normalized))
258- for _, entry := range normalized {
259- target := entry.Host
260- fetchedAt := entry.FetchedAt.UnixNano()
261- incarnation := hex.EncodeToString(entry.Incarnation[:])
262- sessions := make([]catalogCacheSession, 0, len(entry.Sessions))
263- for _, session := range entry.Sessions {
264- name := session.Name
265- state := session.State
266- ephemeral := session.Ephemeral
267- attached := session.Attached
268- id := session.LifecycleID
269- tabs := make([]catalogue.RemoteCatalogTab, len(session.Tabs))
270- copy(tabs, session.Tabs)
271- row := catalogCacheSession{LifecycleID: &id, Name: &name, State: &state, Ephemeral: &ephemeral, Tabs: &tabs, Attached: &attached}
272- if session.LastUsedSeq != 0 {
273- lastUsed := session.LastUsedSeq
274- row.LastUsedSeq = &lastUsed
275- }
276- if session.ActiveTabID != "" {
277- active := session.ActiveTabID
278- row.ActiveTabID = &active
279- }
280- sessions = append(sessions, row)
281- }
282- hosts = append(hosts, catalogCacheHost{
283- Target: &target,
284- FetchedAtUnixNano: &fetchedAt,
285- Incarnation: &incarnation,
286- Sessions: &sessions,
287- })
288- }
289- version := catalogCacheFileVersion
290- payload, err := json.Marshal(catalogCacheFile{Version: &version, Hosts: &hosts})
291- if err != nil {
292- return err
293- }
294- return atomicReplacePrivateFile(c.path, append(payload, '\n'))
295-}
296-
297-func normalizeCatalogCacheEntries(entries []catalogue.RemoteCatalogCacheEntry) ([]catalogue.RemoteCatalogCacheEntry, error) {
298- if err := catalogue.ValidateRemoteCatalogCacheEntries(entries); err != nil {
299- return nil, err
300- }
301- normalized := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries))
302- hosts := make(map[string]struct{}, len(entries))
303- for _, entry := range entries {
304- if err := validateHostTarget(entry.Host); err != nil {
305- return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err)
306- }
307- if entry.FetchedAt.UnixNano() <= 0 {
308- return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host)
309- }
310- if _, duplicate := hosts[entry.Host]; duplicate {
311- return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host)
312- }
313- hosts[entry.Host] = struct{}{}
314-
315- copyEntry := catalogue.RemoteCatalogCacheEntry{
316- Host: entry.Host,
317- FetchedAt: entry.FetchedAt,
318- Incarnation: entry.Incarnation,
319- Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)),
320- }
321- sessions := make(map[string]struct{}, len(entry.Sessions))
322- for _, session := range entry.Sessions {
323- if !utf8.ValidString(session.Name) || !utf8.ValidString(string(session.State)) {
324- return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8")
325- }
326- if err := domain.ValidateSessionName(session.Name); err != nil {
327- return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err)
328- }
329- if !session.State.Valid() {
330- return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State)
331- }
332- if _, duplicate := sessions[session.Name]; duplicate {
333- return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host)
334- }
335- sessions[session.Name] = struct{}{}
336- copySession := session
337- copySession.Tabs = make([]catalogue.RemoteCatalogTab, len(session.Tabs))
338- for i, tab := range session.Tabs {
339- copySession.Tabs[i] = catalogue.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name}
340- }
341- copyEntry.Sessions = append(copyEntry.Sessions, copySession)
342- }
343- sort.Slice(copyEntry.Sessions, func(i, j int) bool {
344- return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name
345- })
346- normalized = append(normalized, copyEntry)
347- }
348- sort.Slice(normalized, func(i, j int) bool {
349- return normalized[i].Host < normalized[j].Host
350- })
351- return normalized, nil
352-}
D internal/adapters/remote/catalog_cache_test.go +0 -356
@@ -1,356 +0,0 @@
1-package remote
2-
3-import (
4- "os"
5- "path/filepath"
6- "reflect"
7- "slices"
8- "strings"
9- "testing"
10- "time"
11-
12- "github.com/bnema/vev/internal/protocol/catalogue"
13- "github.com/bnema/vev/pkg/safedir"
14-)
15-
16-func TestCatalogCachePath(t *testing.T) {
17- stateDir := filepath.Join(t.TempDir(), "state")
18- want := filepath.Join(stateDir, "remote-catalog-cache.json")
19- if got := CatalogCachePath(stateDir); got != want {
20- t.Fatalf("CatalogCachePath(%q) = %q, want %q", stateDir, got, want)
21- }
22-}
23-
24-func TestCatalogCacheStoreLoad(t *testing.T) {
25- t.Parallel()
26-
27- path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
28- cache := NewFileCatalogCache(path)
29- fetchedAt := time.Unix(0, 1780000000000000000)
30- entries := []catalogue.RemoteCatalogCacheEntry{
31- {
32- Host: "zebra",
33- FetchedAt: fetchedAt,
34- Incarnation: [16]byte{9},
35- Sessions: []catalogue.RemoteCatalogSession{
36- {
37- LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2",
38- Tabs: []catalogue.RemoteCatalogTab{{ID: "work-1", Detail: "dynamic", Attention: true}, {ID: "work-2", Index: 1}},
39- },
40- {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
41- },
42- },
43- {
44- Host: "arch",
45- FetchedAt: fetchedAt.Add(time.Second),
46- Incarnation: [16]byte{7},
47- Sessions: []catalogue.RemoteCatalogSession{},
48- },
49- }
50-
51- if err := cache.Store(entries); err != nil {
52- t.Fatalf("Store() error = %v", err)
53- }
54- raw, err := os.ReadFile(path)
55- if err != nil {
56- t.Fatalf("read cache: %v", err)
57- }
58- arch := strings.Index(string(raw), `"target":"arch"`)
59- zebra := strings.Index(string(raw), `"target":"zebra"`)
60- if arch < 0 || zebra < 0 || arch >= zebra {
61- t.Fatalf("stored hosts are not ordered: %s", raw)
62- }
63- alpha := strings.Index(string(raw), `"name":"alpha"`)
64- work := strings.Index(string(raw), `"name":"work"`)
65- if alpha < 0 || work < 0 || alpha >= work {
66- t.Fatalf("stored sessions are not ordered: %s", raw)
67- }
68- if !strings.Contains(string(raw), `"version":4`) || !strings.Contains(string(raw), `"tabs":[{"id":"work-1"`) {
69- t.Fatalf("stored cache does not pin the v4 typed-tab contract: %s", raw)
70- }
71- if strings.Contains(string(raw), `"detail"`) || strings.Contains(string(raw), `"attention"`) {
72- t.Fatalf("stored cache contains dynamic tab fields: %s", raw)
73- }
74- got, err := cache.Load()
75- if err != nil {
76- t.Fatalf("Load() error = %v", err)
77- }
78- want := []catalogue.RemoteCatalogCacheEntry{
79- {
80- Host: "arch",
81- FetchedAt: fetchedAt.Add(time.Second),
82- Incarnation: [16]byte{7},
83- Sessions: []catalogue.RemoteCatalogSession{},
84- },
85- {
86- Host: "zebra",
87- FetchedAt: fetchedAt,
88- Incarnation: [16]byte{9},
89- Sessions: []catalogue.RemoteCatalogSession{
90- {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
91- {LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", Tabs: []catalogue.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}},
92- },
93- },
94- }
95- if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) {
96- t.Fatalf("Load() = %#v, want %#v", got, want)
97- }
98-
99- info, err := os.Stat(path)
100- if err != nil {
101- t.Fatalf("stat cache: %v", err)
102- }
103- if perm := info.Mode().Perm(); perm != 0o600 {
104- t.Fatalf("cache permissions = %04o, want 0600", perm)
105- }
106- dirInfo, err := os.Lstat(filepath.Dir(path))
107- if err != nil {
108- t.Fatalf("stat cache directory: %v", err)
109- }
110- if perm := dirInfo.Mode().Perm(); perm != 0o700 {
111- t.Fatalf("cache directory permissions = %04o, want 0700", perm)
112- }
113- entriesOnDisk, err := os.ReadDir(filepath.Dir(path))
114- if err != nil {
115- t.Fatalf("read cache directory: %v", err)
116- }
117- for _, entry := range entriesOnDisk {
118- if strings.Contains(entry.Name(), ".tmp") {
119- t.Fatalf("temporary file left behind: %q", entry.Name())
120- }
121- }
122-}
123-
124-func TestCatalogCacheLoadMigratesExactV2TabList(t *testing.T) {
125- t.Parallel()
126-
127- path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
128- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
129- t.Fatalf("mkdir: %v", err)
130- }
131- raw := []byte(`{"version":2,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1780000000000000000,"sessions":[{"lifecycle_id":"02000000000000000000000000000000","name":"dumber","state":"down","ephemeral":false,"tab_list":[],"attached":false},{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"last_used_seq":42,"tab_list":[{"id":"t_work","index":0,"name":"shell"}],"active_tab_id":"t_work","attached":false}]}]}`)
132- if err := os.WriteFile(path, raw, 0o600); err != nil {
133- t.Fatalf("write cache: %v", err)
134- }
135-
136- got, err := NewFileCatalogCache(path).Load()
137- if err != nil {
138- t.Fatalf("Load() error = %v", err)
139- }
140- want := []catalogue.RemoteCatalogCacheEntry{{
141- Host: "user@arch",
142- FetchedAt: time.Unix(0, 1780000000000000000),
143- Sessions: []catalogue.RemoteCatalogSession{
144- {LifecycleID: [16]byte{2}, Name: "dumber", State: catalogue.RemoteCatalogSessionDown, Tabs: []catalogue.RemoteCatalogTab{}},
145- {
146- LifecycleID: [16]byte{1}, Name: "work", State: catalogue.RemoteCatalogSessionUp,
147- LastUsedSeq: 42, Tabs: []catalogue.RemoteCatalogTab{{ID: "t_work", Name: "shell"}}, ActiveTabID: "t_work",
148- },
149- },
150- }}
151- if !slices.EqualFunc(got, want, equalRemoteCatalogCacheEntry) {
152- t.Fatalf("Load() = %#v, want %#v", got, want)
153- }
154- after, err := os.ReadFile(path)
155- if err != nil {
156- t.Fatalf("read cache after load: %v", err)
157- }
158- if !slices.Equal(after, raw) {
159- t.Fatalf("Load changed v2 cache: got %q, want %q", after, raw)
160- }
161-}
162-
163-func TestCatalogCacheLegacyV3LoadsWithoutIncarnation(t *testing.T) {
164- t.Parallel()
165-
166- path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
167- if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
168- t.Fatalf("mkdir: %v", err)
169- }
170- raw := []byte(`{"version":3,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1780000000000000000,"sessions":[]}]}`)
171- if err := os.WriteFile(path, raw, 0o600); err != nil {
172- t.Fatalf("write cache: %v", err)
173- }
174-
175- got, err := NewFileCatalogCache(path).Load()
176- if err != nil {
177- t.Fatalf("Load() error = %v", err)
178- }
179- if len(got) != 1 || got[0].Host != "user@arch" {
180- t.Fatalf("Load() = %#v, want one user@arch entry", got)
181- }
182- if got[0].Incarnation != [16]byte{} {
183- t.Fatalf("legacy entry incarnation = %x, want zero (unbound advisory data)", got[0].Incarnation)
184- }
185-}
186-
187-func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
188- t.Parallel()
189-
190- cases := []struct {
191- name string
192- raw []byte
193- }{
194- {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)},
195- {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)},
196- {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"active_tab_id":"t_work","attached":false}]}]}`)},
197- {name: "unknown version", raw: []byte(`{"version":5,"hosts":[]}`)},
198- {name: "v4 missing incarnation", raw: []byte(`{"version":4,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]}]}`)},
199- {name: "v4 malformed incarnation", raw: []byte(`{"version":4,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"incarnation":"not-hex","sessions":[]}]}`)},
200- {name: "v4 short incarnation", raw: []byte(`{"version":4,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"incarnation":"0123","sessions":[]}]}`)},
201- {name: "missing hosts", raw: []byte(`{"version":3}`)},
202- {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)},
203- {name: "null sessions", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)},
204- {name: "zero fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":0,"sessions":[]}]}`)},
205- {name: "negative fetched at", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":-1,"sessions":[]}]}`)},
206- {name: "duplicate hosts", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]},{"target":"arch","fetched_at_unix_nano":2,"sessions":[]}]}`)},
207- {name: "missing exact session fields", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"name":"work","state":"up","ephemeral":false,"tabs":[],"attached":false}]}]}`)},
208- {name: "count-only tabs", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}]}`)},
209- {name: "unknown field", raw: []byte(`{"version":3,"hosts":[],"future":true}`)},
210- {name: "invalid utf8", raw: []byte("{\"version\":3,\"hosts\":[{\"target\":\"\xff\",\"fetched_at_unix_nano\":1,\"sessions\":[]}]}")},
211- }
212- for _, tc := range cases {
213- t.Run(tc.name, func(t *testing.T) {
214- t.Parallel()
215- path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
216- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
217- t.Fatalf("mkdir: %v", err)
218- }
219- if err := os.WriteFile(path, tc.raw, 0o600); err != nil {
220- t.Fatalf("write cache: %v", err)
221- }
222-
223- if _, err := NewFileCatalogCache(path).Load(); err == nil {
224- t.Fatal("Load() error = nil, want malformed cache error")
225- }
226- after, err := os.ReadFile(path)
227- if err != nil {
228- t.Fatalf("read cache: %v", err)
229- }
230- if !slices.Equal(after, tc.raw) {
231- t.Fatalf("invalid cache changed: got %q, want %q", after, tc.raw)
232- }
233- })
234- }
235-}
236-
237-func TestCatalogCacheStoreFailurePreservesExistingFile(t *testing.T) {
238- t.Parallel()
239-
240- path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
241- cache := NewFileCatalogCache(path)
242- valid := []catalogue.RemoteCatalogCacheEntry{{
243- Host: "arch",
244- FetchedAt: time.Unix(0, 1),
245- Sessions: []catalogue.RemoteCatalogSession{},
246- }}
247- if err := cache.Store(valid); err != nil {
248- t.Fatalf("initial Store() error = %v", err)
249- }
250- before, err := os.ReadFile(path)
251- if err != nil {
252- t.Fatalf("read initial cache: %v", err)
253- }
254-
255- invalid := []catalogue.RemoteCatalogCacheEntry{{
256- Host: "arch",
257- FetchedAt: time.Time{},
258- Sessions: []catalogue.RemoteCatalogSession{},
259- }}
260- if err := cache.Store(invalid); err == nil {
261- t.Fatal("Store() error = nil, want validation error")
262- }
263- after, err := os.ReadFile(path)
264- if err != nil {
265- t.Fatalf("read cache after failed Store: %v", err)
266- }
267- if !slices.Equal(after, before) {
268- t.Fatalf("failed Store changed cache: got %q, want %q", after, before)
269- }
270-}
271-
272-func TestCatalogCacheStoreRejectsNonPositiveFetchedTimes(t *testing.T) {
273- t.Parallel()
274-
275- for _, tc := range []struct {
276- name string
277- fetchedAt time.Time
278- }{
279- {name: "zero Unix nano", fetchedAt: time.Unix(0, 0)},
280- {name: "negative Unix nano", fetchedAt: time.Unix(0, -1)},
281- } {
282- t.Run(tc.name, func(t *testing.T) {
283- t.Parallel()
284- path := filepath.Join(t.TempDir(), "remote-catalog-cache.json")
285- entries := []catalogue.RemoteCatalogCacheEntry{{
286- Host: "arch",
287- FetchedAt: tc.fetchedAt,
288- Sessions: []catalogue.RemoteCatalogSession{},
289- }}
290-
291- err := NewFileCatalogCache(path).Store(entries)
292-
293- if err == nil {
294- t.Fatal("Store() error = nil, want non-positive fetched time error")
295- }
296- if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
297- t.Fatalf("failed Store created cache: stat error = %v", statErr)
298- }
299- })
300- }
301-}
302-
303-func TestCatalogCacheStoreHardensExistingFilePermissions(t *testing.T) {
304- t.Parallel()
305-
306- path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
307- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
308- t.Fatalf("mkdir: %v", err)
309- }
310- if err := os.WriteFile(path, []byte(`{"version":1,"hosts":[]}`), 0o644); err != nil {
311- t.Fatalf("seed cache: %v", err)
312- }
313-
314- cache := NewFileCatalogCache(path)
315- if err := cache.Store([]catalogue.RemoteCatalogCacheEntry{}); err != nil {
316- t.Fatalf("Store() error = %v", err)
317- }
318- info, err := os.Stat(path)
319- if err != nil {
320- t.Fatalf("stat cache: %v", err)
321- }
322- if perm := info.Mode().Perm(); perm != 0o600 {
323- t.Fatalf("cache permissions = %04o, want 0600", perm)
324- }
325-}
326-
327-func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
328- t.Parallel()
329-
330- dir := filepath.Join(t.TempDir(), "vev")
331- if err := os.MkdirAll(dir, 0o700); err != nil {
332- t.Fatalf("mkdir parent: %v", err)
333- }
334- path := filepath.Join(dir, "remote-catalog-cache.json")
335- if err := os.Mkdir(path, 0o700); err != nil {
336- t.Fatalf("mkdir destination: %v", err)
337- }
338-
339- cache := NewFileCatalogCache(path)
340- if err := cache.Store([]catalogue.RemoteCatalogCacheEntry{}); err == nil {
341- t.Fatal("Store() error = nil, want rename error")
342- }
343- entries, err := os.ReadDir(dir)
344- if err != nil {
345- t.Fatalf("read cache directory: %v", err)
346- }
347- for _, entry := range entries {
348- if strings.Contains(entry.Name(), ".tmp") {
349- t.Fatalf("temporary file left behind after failed rename: %q", entry.Name())
350- }
351- }
352-}
353-
354-func equalRemoteCatalogCacheEntry(a, b catalogue.RemoteCatalogCacheEntry) bool {
355- return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && a.Incarnation == b.Incarnation && reflect.DeepEqual(a.Sessions, b.Sessions)
356-}
D internal/adapters/remote/catalog_test.go +0 -632
@@ -1,632 +0,0 @@
1-package remote
2-
3-import (
4- "context"
5- "encoding/json"
6- "errors"
7- "fmt"
8- "os/exec"
9- "reflect"
10- "strings"
11- "testing"
12- "time"
13-
14- "github.com/bnema/vev/internal/adapters/sshstdio"
15- "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/internal/protocol"
17- "github.com/bnema/vev/internal/protocol/catalogue"
18-)
19-
20-func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
21- t.Parallel()
22-
23- tests := []struct {
24- name string
25- target string
26- }{
27- {name: "plain host", target: "arch"},
28- {name: "metacharacters stay single local arg", target: "user@host;touch/tmp/pwn"},
29- {name: "user at host", target: "build@mule"},
30- }
31-
32- for _, tt := range tests {
33- t.Run(tt.name, func(t *testing.T) {
34- t.Parallel()
35- var gotPath string
36- var gotArgs []string
37- client := &CatalogClient{
38- command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
39- gotPath = name
40- gotArgs = append([]string(nil), args...)
41- return stdoutCmd(ctx, mustCatalogJSON(t, catalogue.RemoteCatalog{
42- ProtocolVersion: protocol.Version,
43- SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
44- Sessions: []catalogue.RemoteCatalogSession{},
45- }))
46- },
47- }
48-
49- if _, err := client.List(context.Background(), tt.target); err != nil {
50- t.Fatalf("List() error = %v", err)
51- }
52-
53- want := sshstdio.BuildCommandForObservation(tt.target, catalogSSHConnectTimeout, "vev", "cmd", "remote-catalog", "--json")
54- if gotPath != want.Path {
55- t.Fatalf("Path = %q, want %q", gotPath, want.Path)
56- }
57- if len(gotArgs) != len(want.Args) {
58- t.Fatalf("Args len = %d, want %d (%q)", len(gotArgs), len(want.Args), gotArgs)
59- }
60- for i := range want.Args {
61- if gotArgs[i] != want.Args[i] {
62- t.Fatalf("Args[%d] = %q, want %q (all %q)", i, gotArgs[i], want.Args[i], gotArgs)
63- }
64- }
65- sep := -1
66- for i, arg := range gotArgs {
67- if arg == "--" {
68- sep = i
69- break
70- }
71- }
72- if sep < 0 || sep+1 >= len(gotArgs) || gotArgs[sep+1] != tt.target {
73- t.Fatalf("observation must keep the target one unquoted local argv word right after --; got %q", gotArgs)
74- }
75- joined := strings.Join(gotArgs[:sep], " ")
76- for _, flag := range []string{"-T", "UpdateHostKeys=no", "ConnectTimeout=", "ConnectionAttempts=1"} {
77- if !strings.Contains(joined, flag) {
78- t.Fatalf("observation argv missing %q; got %q", flag, gotArgs)
79- }
80- }
81- for _, hostPolicy := range []string{"BatchMode=", "StrictHostKeyChecking="} {
82- if strings.Contains(joined, hostPolicy) {
83- t.Fatalf("observation argv overrides host policy %q; got %q", hostPolicy, gotArgs)
84- }
85- }
86- if sep+2 >= len(gotArgs) || !strings.Contains(gotArgs[sep+2], "'vev'") || !strings.Contains(gotArgs[sep+2], "'remote-catalog'") {
87- t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs)
88- }
89- })
90- }
91-}
92-
93-func TestRemoteCatalogClientRejectsUnsafeTarget(t *testing.T) {
94- t.Parallel()
95- client := NewCatalogClient()
96- _, err := client.List(context.Background(), "bad host")
97- if err == nil {
98- t.Fatal("List() error = nil, want target validation error")
99- }
100-}
101-
102-func TestRemoteCatalogClientDecode(t *testing.T) {
103- t.Parallel()
104-
105- valid := catalogue.RemoteCatalog{
106- ProtocolVersion: protocol.Version,
107- SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
108- Sessions: []catalogue.RemoteCatalogSession{
109- {LifecycleID: [16]byte{1}, Name: "work", State: "up", Ephemeral: false, Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}}, Attached: true},
110- },
111- }
112- validJSON := mustCatalogJSON(t, valid)
113-
114- tests := []struct {
115- name string
116- stdout string
117- stderr string
118- exit int
119- want catalogue.RemoteCatalog
120- wantErr error
121- check func(t *testing.T, err error)
122- }{
123- {
124- name: "matching protocol version",
125- stdout: validJSON,
126- want: valid,
127- },
128- {
129- name: "trailing whitespace accepted",
130- stdout: validJSON + " \n\t",
131- want: valid,
132- },
133- {
134- name: "mismatching protocol version",
135- stdout: mustCatalogJSON(t, catalogue.RemoteCatalog{ProtocolVersion: protocol.Version + 1, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{}}),
136- check: func(t *testing.T, err error) {
137- t.Helper()
138- var mismatch *catalogue.RemoteCatalogVersionMismatchError
139- if !errors.As(err, &mismatch) {
140- t.Fatalf("error = %v, want RemoteCatalogVersionMismatchError", err)
141- }
142- if mismatch.Kind != "protocol" || mismatch.Got != protocol.Version+1 || mismatch.Want != protocol.Version {
143- t.Fatalf("mismatch = %#v", mismatch)
144- }
145- },
146- },
147- {
148- name: "obsolete count-only schema is classified before session decoding",
149- stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":2,"sessions":[{"tabs":1}]}`,
150- protocol.Version),
151- check: func(t *testing.T, err error) {
152- t.Helper()
153- var mismatch *catalogue.RemoteCatalogVersionMismatchError
154- if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 2 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion {
155- t.Fatalf("error = %v, want obsolete catalogue version mismatch", err)
156- }
157- },
158- },
159- {
160- name: "unknown catalogue schema",
161- stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":4,"sessions":[]}`, protocol.Version),
162- check: func(t *testing.T, err error) {
163- t.Helper()
164- var mismatch *catalogue.RemoteCatalogVersionMismatchError
165- if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 4 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion {
166- t.Fatalf("error = %v, want unknown catalogue version mismatch", err)
167- }
168- },
169- },
170- {
171- name: "malformed json",
172- stdout: `{"protocol_version":`,
173- wantErr: errCatalogDecode,
174- },
175- {
176- name: "truncated json",
177- stdout: `{"protocol_version":19,"sessions":[`,
178- wantErr: errCatalogDecode,
179- },
180- {
181- name: "count-only tabs rejected",
182- stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}`,
183- protocol.Version, catalogue.RemoteCatalogSchemaVersion),
184- wantErr: errCatalogDecode,
185- },
186- {
187- name: "unknown fields rejected",
188- stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[],"future":true}`,
189- protocol.Version, catalogue.RemoteCatalogSchemaVersion),
190- wantErr: errCatalogDecode,
191- },
192- {
193- name: "missing required session field",
194- stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","tabs":[],"attached":false}]}`,
195- protocol.Version, catalogue.RemoteCatalogSchemaVersion),
196- wantErr: errCatalogRequired,
197- },
198- {
199- name: "trailing non-whitespace",
200- stdout: strings.TrimSpace(validJSON) + `{"extra":true}`,
201- wantErr: errCatalogTrailing,
202- },
203- {
204- name: "missing protocol_version",
205- stdout: `{"sessions":[]}` + "\n",
206- wantErr: errCatalogRequired,
207- },
208- {
209- name: "missing catalogue schema",
210- stdout: fmt.Sprintf(`{"protocol_version":%d,"sessions":[]}`+"\n", protocol.Version),
211- check: func(t *testing.T, err error) {
212- t.Helper()
213- var mismatch *catalogue.RemoteCatalogVersionMismatchError
214- if !errors.As(err, &mismatch) || mismatch.Kind != "catalog" || mismatch.Got != 0 || mismatch.Want != catalogue.RemoteCatalogSchemaVersion {
215- t.Fatalf("error = %v, want missing catalogue version mismatch", err)
216- }
217- },
218- },
219- {
220- name: "missing sessions",
221- stdout: fmt.Sprintf(`{"protocol_version":%d,"schema_version":%d}`+"\n", protocol.Version, catalogue.RemoteCatalogSchemaVersion),
222- wantErr: errCatalogRequired,
223- },
224- {
225- name: "invalid session name",
226- stdout: mustCatalogJSON(t, catalogue.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{{
227- LifecycleID: [16]byte{1}, Name: "bad name", State: "up", Tabs: []catalogue.RemoteCatalogTab{},
228- }}}),
229- wantErr: catalogue.ErrInvalidRemoteCatalog,
230- },
231- {
232- name: "invalid session state",
233- stdout: mustCatalogJSON(t, catalogue.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{{
234- LifecycleID: [16]byte{1}, Name: "work", State: "weird", Tabs: []catalogue.RemoteCatalogTab{},
235- }}}),
236- wantErr: catalogue.ErrRemoteCatalogUnknownState,
237- },
238- {
239- name: "remote exit with stderr",
240- stdout: "",
241- stderr: "connection refused",
242- exit: 255,
243- wantErr: errCatalogSSH,
244- },
245- }
246-
247- for _, tt := range tests {
248- t.Run(tt.name, func(t *testing.T) {
249- t.Parallel()
250- client := &CatalogClient{
251- command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
252- if tt.exit != 0 {
253- cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat >&2; exit %d", tt.exit))
254- cmd.Stdin = strings.NewReader(tt.stderr)
255- return cmd
256- }
257- return stdoutCmd(ctx, tt.stdout)
258- },
259- }
260- got, err := client.List(context.Background(), "arch")
261- if tt.check != nil {
262- if err == nil {
263- t.Fatal("List() error = nil, want error")
264- }
265- tt.check(t, err)
266- return
267- }
268- if tt.wantErr != nil {
269- if !errors.Is(err, tt.wantErr) {
270- t.Fatalf("List() error = %v, want %v", err, tt.wantErr)
271- }
272- return
273- }
274- if err != nil {
275- t.Fatalf("List() error = %v", err)
276- }
277- if got.ProtocolVersion != tt.want.ProtocolVersion {
278- t.Fatalf("ProtocolVersion = %d, want %d", got.ProtocolVersion, tt.want.ProtocolVersion)
279- }
280- if got.SchemaVersion != tt.want.SchemaVersion {
281- t.Fatalf("SchemaVersion = %d, want %d", got.SchemaVersion, tt.want.SchemaVersion)
282- }
283- if len(got.Sessions) != len(tt.want.Sessions) {
284- t.Fatalf("Sessions len = %d, want %d", len(got.Sessions), len(tt.want.Sessions))
285- }
286- for i := range tt.want.Sessions {
287- if !reflect.DeepEqual(got.Sessions[i], tt.want.Sessions[i]) {
288- t.Fatalf("Sessions[%d] = %#v, want %#v", i, got.Sessions[i], tt.want.Sessions[i])
289- }
290- }
291- })
292- }
293-}
294-
295-func exitError(t *testing.T, code int) error {
296- t.Helper()
297- err := exec.Command("sh", "-c", fmt.Sprintf("exit %d", code)).Run()
298- if err == nil {
299- t.Fatalf("exit %d returned nil error", code)
300- }
301- return err
302-}
303-
304-func TestClassifyCatalogError(t *testing.T) {
305- t.Parallel()
306-
307- sshWrapped := func(code int, stderr string) error {
308- return fmt.Errorf("%w: %w: %s", errCatalogSSH, exitError(t, code), stderr)
309- }
310- mismatch := &catalogue.RemoteCatalogVersionMismatchError{Got: 1, Want: 2, Kind: "protocol"}
311-
312- tests := []struct {
313- name string
314- err error
315- want domain.RemoteFailureKind
316- }{
317- {name: "nil", err: nil, want: domain.RemoteFailureNone},
318- {name: "deadline", err: context.DeadlineExceeded, want: domain.RemoteFailureTimeout},
319- {name: "canceled stays transport", err: context.Canceled, want: domain.RemoteFailureTransport},
320- {name: "version mismatch", err: mismatch, want: domain.RemoteFailureIncompatible},
321- {name: "wrapped version mismatch", err: fmt.Errorf("decode: %w", mismatch), want: domain.RemoteFailureIncompatible},
322- {name: "too large", err: errCatalogTooLarge, want: domain.RemoteFailureInvalidResponse},
323- {name: "decode", err: fmt.Errorf("%w: bad", errCatalogDecode), want: domain.RemoteFailureInvalidResponse},
324- {name: "trailing", err: errCatalogTrailing, want: domain.RemoteFailureInvalidResponse},
325- {name: "required", err: errCatalogRequired, want: domain.RemoteFailureInvalidResponse},
326- {name: "invalid catalog", err: fmt.Errorf("%w: bad session", catalogue.ErrInvalidRemoteCatalog), want: domain.RemoteFailureInvalidResponse},
327- {name: "trust failure", err: sshWrapped(255, "Host key verification failed."), want: domain.RemoteFailureTrust},
328- {name: "changed host key", err: sshWrapped(255, "@@@@@@@@@@@@\nWARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!\n@@@@@@@@@@@@"), want: domain.RemoteFailureTrust},
329- {name: "auth failure", err: sshWrapped(255, "user@arch: Permission denied (publickey)."), want: domain.RemoteFailureAuthentication},
330- {name: "bare too many auth failures stay transport", err: sshWrapped(255, "Too many authentication failures"), want: domain.RemoteFailureTransport},
331- {name: "remote command exits 255 with auth text stays transport", err: sshWrapped(255, "vev: remote helper failed\nPermission denied (publickey)"), want: domain.RemoteFailureTransport},
332- {name: "remote command timeout text stays transport", err: sshWrapped(255, "catalog fetch timed out"), want: domain.RemoteFailureTransport},
333- {name: "connect timeout", err: sshWrapped(255, "ssh: connect to host arch port 22: Connection timed out"), want: domain.RemoteFailureTimeout},
334- {name: "unrecognized ssh failure stays transport", err: sshWrapped(255, "some new mysterious error"), want: domain.RemoteFailureTransport},
335- {name: "remote command failure stays transport", err: sshWrapped(1, "remote vev exploded"), want: domain.RemoteFailureTransport},
336- {name: "auth text without client exit stays transport", err: fmt.Errorf("%w: %s", errCatalogSSH, "Permission denied (publickey)"), want: domain.RemoteFailureTransport},
337- {name: "plain error stays transport", err: errors.New("boom"), want: domain.RemoteFailureTransport},
338- }
339- for _, tt := range tests {
340- t.Run(tt.name, func(t *testing.T) {
341- t.Parallel()
342- if got := ClassifyCatalogError(tt.err); got != tt.want {
343- t.Fatalf("ClassifyCatalogError(%v) = %v, want %v", tt.err, got, tt.want)
344- }
345- })
346- }
347-}
348-
349-func TestRemoteCatalogClientContextCanceled(t *testing.T) {
350- t.Parallel()
351- ctx, cancel := context.WithCancel(context.Background())
352- cancel()
353- client := NewCatalogClient()
354- _, err := client.List(ctx, "arch")
355- if !errors.Is(err, context.Canceled) {
356- t.Fatalf("List() error = %v, want context.Canceled", err)
357- }
358-}
359-
360-func TestRemoteCatalogClientAppliesLifetimeAndWaitDelay(t *testing.T) {
361- t.Parallel()
362-
363- var commandCtx context.Context
364- var cmd *exec.Cmd
365- client := &CatalogClient{
366- command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
367- commandCtx = ctx
368- cmd = stdoutCmd(ctx, mustCatalogJSON(t, catalogue.RemoteCatalog{
369- ProtocolVersion: protocol.Version,
370- SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
371- Sessions: []catalogue.RemoteCatalogSession{},
372- }))
373- return cmd
374- },
375- }
376- if _, err := client.List(context.Background(), "arch"); err != nil {
377- t.Fatalf("List() error = %v", err)
378- }
379- deadline, ok := commandCtx.Deadline()
380- if !ok {
381- t.Fatal("command context missing default lifetime deadline")
382- }
383- if remaining := time.Until(deadline); remaining <= 0 || remaining > catalogCommandTimeout {
384- t.Fatalf("deadline remaining = %v, want within (0, %v]", remaining, catalogCommandTimeout)
385- }
386- if cmd.WaitDelay != catalogCommandWaitDelay {
387- t.Fatalf("WaitDelay = %v, want %v", cmd.WaitDelay, catalogCommandWaitDelay)
388- }
389-}
390-
391-func TestRemoteCatalogClientClassifiesRunContextErrors(t *testing.T) {
392- tests := []struct {
393- name string
394- newContext func() (context.Context, context.CancelFunc)
395- timeout time.Duration
396- cancel bool
397- want error
398- }{
399- {
400- name: "own deadline",
401- newContext: func() (context.Context, context.CancelFunc) {
402- return context.WithCancel(context.Background())
403- },
404- timeout: 20 * time.Millisecond,
405- want: context.DeadlineExceeded,
406- },
407- {
408- name: "parent deadline",
409- newContext: func() (context.Context, context.CancelFunc) {
410- return context.WithTimeout(context.Background(), 20*time.Millisecond)
411- },
412- timeout: time.Minute,
413- want: context.DeadlineExceeded,
414- },
415- {
416- name: "parent cancellation",
417- newContext: func() (context.Context, context.CancelFunc) {
418- return context.WithCancel(context.Background())
419- },
420- timeout: time.Minute,
421- cancel: true,
422- want: context.Canceled,
423- },
424- }
425-
426- for _, tt := range tests {
427- t.Run(tt.name, func(t *testing.T) {
428- ctx, cancel := tt.newContext()
429- defer cancel()
430- started := make(chan struct{})
431- client := &CatalogClient{
432- timeout: tt.timeout,
433- command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
434- close(started)
435- return exec.CommandContext(ctx, "sleep", "10")
436- },
437- }
438-
439- errCh := make(chan error, 1)
440- go func() {
441- _, err := client.List(ctx, "arch")
442- errCh <- err
443- }()
444- select {
445- case <-started:
446- case <-time.After(2 * time.Second):
447- t.Fatal("command did not start")
448- }
449- if tt.cancel {
450- cancel()
451- }
452- select {
453- case err := <-errCh:
454- if !errors.Is(err, tt.want) {
455- t.Fatalf("List() error = %v, want %v", err, tt.want)
456- }
457- case <-time.After(2 * time.Second):
458- t.Fatal("List() did not return after context ended")
459- }
460- })
461- }
462-}
463-
464-func TestCatalogOutputSafety(t *testing.T) {
465- t.Parallel()
466-
467- tests := []struct {
468- name string
469- command func(ctx context.Context) *exec.Cmd
470- wantErr error
471- check func(t *testing.T, err error)
472- }{
473- {
474- name: "oversized stdout",
475- command: func(ctx context.Context) *exec.Cmd {
476- return exec.CommandContext(ctx, "sh", "-c",
477- fmt.Sprintf("head -c %d /dev/zero", maxCatalogBytes+1))
478- },
479- wantErr: errCatalogTooLarge,
480- check: func(t *testing.T, err error) {
481- t.Helper()
482- if strings.Contains(err.Error(), strings.Repeat("\x00", 64)) {
483- t.Fatalf("error embeds oversized stdout: %v", err)
484- }
485- },
486- },
487- {
488- name: "oversized stderr",
489- command: func(ctx context.Context) *exec.Cmd {
490- return exec.CommandContext(ctx, "sh", "-c",
491- fmt.Sprintf("head -c %d /dev/zero >&2; exit 1", maxCatalogDiagnosticBytes+1))
492- },
493- wantErr: errCatalogTooLarge,
494- check: func(t *testing.T, err error) {
495- t.Helper()
496- if strings.Contains(err.Error(), strings.Repeat("\x00", 64)) {
497- t.Fatalf("error embeds oversized stderr: %v", err)
498- }
499- if errors.Is(err, errCatalogSSH) {
500- t.Fatalf("oversized stderr must not wrap SSH with captured output: %v", err)
501- }
502- },
503- },
504- {
505- name: "ansi control stderr",
506- command: func(ctx context.Context) *exec.Cmd {
507- cmd := exec.CommandContext(ctx, "sh", "-c", "cat >&2; exit 255")
508- cmd.Stdin = strings.NewReader("\x1b[31mconnection refused\x1b[0m\x07")
509- return cmd
510- },
511- wantErr: errCatalogSSH,
512- check: func(t *testing.T, err error) {
513- t.Helper()
514- msg := err.Error()
515- if strings.ContainsRune(msg, '\x1b') || strings.ContainsRune(msg, '\x07') {
516- t.Fatalf("error retains control/ANSI bytes: %q", msg)
517- }
518- if strings.Contains(msg, "[31m") || strings.Contains(msg, "[0m") {
519- t.Fatalf("error retains terminal escape remnants: %q", msg)
520- }
521- if !strings.Contains(msg, "connection refused") {
522- t.Fatalf("error = %q, want printable diagnostic text preserved", msg)
523- }
524- },
525- },
526- }
527-
528- for _, tt := range tests {
529- t.Run(tt.name, func(t *testing.T) {
530- t.Parallel()
531- client := &CatalogClient{
532- command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
533- return tt.command(ctx)
534- },
535- }
536- _, err := client.List(context.Background(), "arch")
537- if !errors.Is(err, tt.wantErr) {
538- t.Fatalf("List() error = %v, want %v", err, tt.wantErr)
539- }
540- if tt.check != nil {
541- tt.check(t, err)
542- }
543- })
544- }
545-}
546-
547-func TestBoundedBufferLimits(t *testing.T) {
548- t.Parallel()
549-
550- tests := []struct {
551- name string
552- limit int
553- chunks [][]byte
554- wantLen int
555- wantOver bool
556- wantBytes string
557- }{
558- {
559- name: "under limit",
560- limit: 8,
561- chunks: [][]byte{[]byte("abcd"), []byte("ef")},
562- wantLen: 6,
563- wantOver: false,
564- wantBytes: "abcdef",
565- },
566- {
567- name: "exact limit",
568- limit: 4,
569- chunks: [][]byte{[]byte("abcd")},
570- wantLen: 4,
571- wantOver: false,
572- wantBytes: "abcd",
573- },
574- {
575- name: "overflow discards remainder",
576- limit: 4,
577- chunks: [][]byte{[]byte("abc"), []byte("defgh")},
578- wantLen: 4,
579- wantOver: true,
580- wantBytes: "abcd",
581- },
582- {
583- name: "further writes stay discarded",
584- limit: 2,
585- chunks: [][]byte{[]byte("abcdef"), []byte("xyz")},
586- wantLen: 2,
587- wantOver: true,
588- wantBytes: "ab",
589- },
590- }
591-
592- for _, tt := range tests {
593- t.Run(tt.name, func(t *testing.T) {
594- t.Parallel()
595- var buf boundedBuffer
596- buf.limit = tt.limit
597- for _, chunk := range tt.chunks {
598- n, err := buf.Write(chunk)
599- if err != nil {
600- t.Fatalf("Write(%q) error = %v", chunk, err)
601- }
602- if n != len(chunk) {
603- t.Fatalf("Write(%q) n = %d, want %d", chunk, n, len(chunk))
604- }
605- }
606- if buf.overflow != tt.wantOver {
607- t.Fatalf("overflow = %v, want %v", buf.overflow, tt.wantOver)
608- }
609- if got := len(buf.Bytes()); got != tt.wantLen {
610- t.Fatalf("len = %d, want %d", got, tt.wantLen)
611- }
612- if got := string(buf.Bytes()); got != tt.wantBytes {
613- t.Fatalf("bytes = %q, want %q", got, tt.wantBytes)
614- }
615- })
616- }
617-}
618-
619-func stdoutCmd(ctx context.Context, stdout string) *exec.Cmd {
620- cmd := exec.CommandContext(ctx, "cat")
621- cmd.Stdin = strings.NewReader(stdout)
622- return cmd
623-}
624-
625-func mustCatalogJSON(t *testing.T, catalog catalogue.RemoteCatalog) string {
626- t.Helper()
627- raw, err := json.Marshal(catalog)
628- if err != nil {
629- t.Fatalf("Marshal: %v", err)
630- }
631- return string(raw) + "\n"
632-}
D internal/adapters/remote/host_registry.go +0 -579
@@ -1,579 +0,0 @@
1-package remote
2-
3-import (
4- "crypto/rand"
5- "encoding/hex"
6- "encoding/json"
7- "errors"
8- "fmt"
9- "log/slog"
10- "os"
11- "path/filepath"
12- "sort"
13- "syscall"
14- "unicode/utf8"
15-
16- "github.com/bnema/vev/internal/domain"
17- "github.com/bnema/vev/internal/ports"
18- "github.com/bnema/vev/pkg/safedir"
19-)
20-
21-const (
22- hostsFileName = "hosts.json"
23- hostsFileVersion = 3
24- hostsBackupSuffix = ".v2.bak"
25- legacyHostsVersion = 2
26- legacyListVersion = 1
27-)
28-
29-// newRemoteRegistration creates a registration with a fresh random
30-// incarnation for a validated endpoint. Domain construction stays
31-// deterministic; only this adapter samples randomness.
32-func newRemoteRegistration(endpoint string) (domain.RemoteRegistration, error) {
33- var incarnation [16]byte
34- if _, err := rand.Read(incarnation[:]); err != nil {
35- return domain.RemoteRegistration{}, fmt.Errorf("remote registration: %w", err)
36- }
37- return domain.NewRemoteRegistration(endpoint, incarnation)
38-}
39-
40-// HostStorePath returns the canonical location of the remote host store in stateDir.
41-func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) }
42-
43-type hostsFile struct {
44- Version int `json:"version"`
45- Pinned []hostRecord `json:"pinned"`
46- Learned []hostRecord `json:"learned"`
47-}
48-
49-// hostRecord is the persisted registration for one endpoint: the exact
50-// configured identity plus a random incarnation and fencing generation.
51-// Incarnation is hex-encoded to stay debuggable in the JSON file.
52-type hostRecord struct {
53- Endpoint string `json:"endpoint"`
54- Incarnation string `json:"incarnation"`
55- Generation uint64 `json:"generation"`
56-}
57-
58-type legacyHostsFile struct {
59- Version int `json:"version"`
60- Pinned []string `json:"pinned"`
61- Learned []string `json:"learned"`
62-}
63-
64-type legacyListHostsFile struct {
65- Version int `json:"version"`
66- Hosts []string `json:"hosts"`
67-}
68-
69-type hostState struct {
70- pinned []domain.RemoteRegistration
71- learned map[string]domain.RemoteRegistration
72-}
73-
74-type fileHostStore struct {
75- path string
76-}
77-
78-// NewFileHostStore returns a unified pinned/learned host store backed by path.
79-func NewFileHostStore(path string) ports.RemoteHostStore {
80- return &fileHostStore{path: path}
81-}
82-
83-var _ ports.RemoteHostStore = (*fileHostStore)(nil)
84-
85-func (s *fileHostStore) Hosts() (pinned, learned []domain.RemoteRegistration, err error) {
86- err = s.withLock(func() error {
87- state, loadErr := s.loadMigratedLocked()
88- if loadErr != nil {
89- return loadErr
90- }
91- pinned = append([]domain.RemoteRegistration(nil), state.pinned...)
92- learned = sortedRegistrations(state.learned)
93- return nil
94- })
95- if err != nil {
96- slog.Debug("remote host store hosts failed", "path", s.path, "err", err)
97- return nil, nil, err
98- }
99- return pinned, learned, nil
100-}
101-
102-func (s *fileHostStore) AddPinned(target string) error {
103- if err := validateHostTarget(target); err != nil {
104- return err
105- }
106- err := s.withLock(func() error {
107- state, err := s.loadMigratedLocked()
108- if err != nil {
109- return err
110- }
111- for _, record := range state.pinned {
112- if record.Endpoint == target {
113- return nil
114- }
115- }
116- // Pinning an existing registration retains its incarnation.
117- if record, ok := state.learned[target]; ok {
118- state.pinned = append(state.pinned, record)
119- } else {
120- record, err := newRemoteRegistration(target)
121- if err != nil {
122- return err
123- }
124- state.pinned = append(state.pinned, record)
125- }
126- return s.saveLocked(state)
127- })
128- if err != nil {
129- slog.Debug("remote host store add pinned failed", "path", s.path, "target", target, "err", err)
130- }
131- return err
132-}
133-
134-func (s *fileHostStore) RemovePinned(target string) error {
135- if err := validateHostTarget(target); err != nil {
136- return err
137- }
138- err := s.withLock(func() error {
139- state, err := s.loadMigratedLocked()
140- if err != nil {
141- return err
142- }
143- next, changed := removeRegistration(state.pinned, target)
144- if !changed {
145- return nil
146- }
147- state.pinned = next
148- return s.saveLocked(state)
149- })
150- if err != nil {
151- slog.Debug("remote host store remove pinned failed", "path", s.path, "target", target, "err", err)
152- }
153- return err
154-}
155-
156-func (s *fileHostStore) Remember(target string) error {
157- if err := validateHostTarget(target); err != nil {
158- return err
159- }
160- err := s.withLock(func() error {
161- state, err := s.loadMigratedLocked()
162- if err != nil {
163- return err
164- }
165- if _, ok := state.learned[target]; ok {
166- return nil
167- }
168- // Remembering an existing registration retains its incarnation.
169- if record, ok := findRegistration(state.pinned, target); ok {
170- state.learned[target] = record
171- } else {
172- record, err := newRemoteRegistration(target)
173- if err != nil {
174- return err
175- }
176- state.learned[target] = record
177- }
178- return s.saveLocked(state)
179- })
180- if err != nil {
181- slog.Debug("remote host store remember failed", "path", s.path, "target", target, "err", err)
182- }
183- return err
184-}
185-
186-func (s *fileHostStore) Forget(target string) error {
187- if err := validateHostTarget(target); err != nil {
188- return err
189- }
190- err := s.withLock(func() error {
191- state, err := s.loadMigratedLocked()
192- if err != nil {
193- return err
194- }
195- if _, ok := state.learned[target]; !ok {
196- return nil
197- }
198- delete(state.learned, target)
199- return s.saveLocked(state)
200- })
201- if err != nil {
202- slog.Debug("remote host store forget failed", "path", s.path, "target", target, "err", err)
203- }
204- return err
205-}
206-
207-func (s *fileHostStore) Remove(target string) (deleted bool, err error) {
208- if err := validateHostTarget(target); err != nil {
209- return false, err
210- }
211- err = s.withLock(func() error {
212- state, err := s.loadMigratedLocked()
213- if err != nil {
214- return err
215- }
216- nextPinned, pinnedChanged := removeRegistration(state.pinned, target)
217- _, learnedPresent := state.learned[target]
218- if !pinnedChanged && !learnedPresent {
219- return nil
220- }
221- // Complete removal drops the incarnation: a later re-add creates
222- // a new registration even though the endpoint string matches.
223- state.pinned = nextPinned
224- delete(state.learned, target)
225- if err := s.saveLocked(state); err != nil {
226- return err
227- }
228- deleted = true
229- return nil
230- })
231- if err != nil {
232- slog.Debug("remote host store remove failed", "path", s.path, "target", target, "err", err)
233- return false, err
234- }
235- return deleted, nil
236-}
237-
238-func (s *fileHostStore) withLock(fn func() error) error {
239- if err := safedir.EnsurePrivate(filepath.Dir(s.path)); err != nil {
240- return err
241- }
242- lock, err := os.OpenFile(s.path+".lock", os.O_CREATE|os.O_RDWR, 0o600)
243- if err != nil {
244- return err
245- }
246- defer func() { _ = lock.Close() }()
247- if err := lock.Chmod(0o600); err != nil {
248- return err
249- }
250- if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
251- return err
252- }
253- defer func() { _ = syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) }()
254- return fn()
255-}
256-
257-// loadMigratedLocked loads the registry, migrating version 1 list files and
258-// version 2 string lists to version 3 registration records. Migration assigns
259-// one fresh random incarnation per endpoint, preserves pinned/learned
260-// ordering and membership, and persists a private pre-migration backup.
261-// Unsupported or corrupt versions fail closed without replacement.
262-func (s *fileHostStore) loadMigratedLocked() (hostState, error) {
263- raw, err := os.ReadFile(s.path)
264- if errors.Is(err, os.ErrNotExist) {
265- return hostState{
266- pinned: make([]domain.RemoteRegistration, 0),
267- learned: make(map[string]domain.RemoteRegistration),
268- }, nil
269- }
270- if err != nil {
271- return hostState{}, err
272- }
273- if !utf8.Valid(raw) {
274- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: invalid UTF-8")
275- }
276- var header struct {
277- Version int `json:"version"`
278- }
279- if err := json.Unmarshal(raw, &header); err != nil {
280- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
281- }
282-
283- switch header.Version {
284- case hostsFileVersion:
285- return decodeHostsV3(raw)
286- case legacyHostsVersion, legacyListVersion:
287- state, err := decodeLegacyHosts(raw, header.Version)
288- if err != nil {
289- return hostState{}, err
290- }
291- if err := s.backupAndSaveMigratedLocked(raw, state); err != nil {
292- return hostState{}, err
293- }
294- return state, nil
295- default:
296- return hostState{}, fmt.Errorf("remote host store: unsupported hosts file version %d", header.Version)
297- }
298-}
299-
300-// backupAndSaveMigratedLocked preserves the pre-migration bytes privately and
301-// atomically replaces the registry with version 3 records. Both steps run
302-// under the existing registry lock.
303-func (s *fileHostStore) backupAndSaveMigratedLocked(raw []byte, state hostState) error {
304- if err := atomicReplacePrivateFile(s.path+hostsBackupSuffix, raw); err != nil {
305- return err
306- }
307- return s.saveLocked(state)
308-}
309-
310-func decodeHostsV3(raw []byte) (hostState, error) {
311- var decoded hostsFile
312- if err := json.Unmarshal(raw, &decoded); err != nil {
313- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
314- }
315- if decoded.Pinned == nil {
316- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned")
317- }
318- if decoded.Learned == nil {
319- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned")
320- }
321- pinned, err := validatePinnedRecords(decoded.Pinned)
322- if err != nil {
323- return hostState{}, err
324- }
325- learned, err := validateLearnedRecords(decoded.Learned)
326- if err != nil {
327- return hostState{}, err
328- }
329- for _, record := range pinned {
330- if other, ok := learned[record.Endpoint]; ok && !other.Equal(record) {
331- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: conflicting identities for %q", record.Endpoint)
332- }
333- }
334- return hostState{pinned: pinned, learned: learned}, nil
335-}
336-
337-func decodeLegacyHosts(raw []byte, version int) (hostState, error) {
338- if version == legacyListVersion {
339- var legacy legacyListHostsFile
340- if err := json.Unmarshal(raw, &legacy); err != nil {
341- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
342- }
343- if legacy.Hosts == nil {
344- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing hosts")
345- }
346- learned, err := migrateTargetsToRecords(legacy.Hosts, nil)
347- if err != nil {
348- return hostState{}, err
349- }
350- return hostState{pinned: []domain.RemoteRegistration{}, learned: learned}, nil
351- }
352- var legacy legacyHostsFile
353- if err := json.Unmarshal(raw, &legacy); err != nil {
354- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
355- }
356- if legacy.Pinned == nil {
357- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned")
358- }
359- if legacy.Learned == nil {
360- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned")
361- }
362- pinned, err := migrateOrderedTargetsToRecords(legacy.Pinned)
363- if err != nil {
364- return hostState{}, err
365- }
366- // One endpoint keeps one registration across memberships: learned
367- // entries reuse the pinned incarnation instead of minting a second
368- // identity that would surface on RemovePinned.
369- reuse := make(map[string]domain.RemoteRegistration, len(pinned))
370- for _, record := range pinned {
371- reuse[record.Endpoint] = record
372- }
373- learned, err := migrateTargetsToRecords(legacy.Learned, reuse)
374- if err != nil {
375- return hostState{}, err
376- }
377- return hostState{pinned: pinned, learned: learned}, nil
378-}
379-
380-func migrateOrderedTargetsToRecords(hosts []string) ([]domain.RemoteRegistration, error) {
381- pinned := make([]domain.RemoteRegistration, 0, len(hosts))
382- seen := make(map[string]struct{}, len(hosts))
383- for _, host := range hosts {
384- if err := validateHostTarget(host); err != nil {
385- return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
386- }
387- if _, dup := seen[host]; dup {
388- return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", host)
389- }
390- seen[host] = struct{}{}
391- record, err := newRemoteRegistration(host)
392- if err != nil {
393- return nil, err
394- }
395- pinned = append(pinned, record)
396- }
397- return pinned, nil
398-}
399-
400-func migrateTargetsToRecords(hosts []string, reuse map[string]domain.RemoteRegistration) (map[string]domain.RemoteRegistration, error) {
401- learned := make(map[string]domain.RemoteRegistration, len(hosts))
402- for _, host := range hosts {
403- if err := validateHostTarget(host); err != nil {
404- return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
405- }
406- if _, dup := learned[host]; dup {
407- return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", host)
408- }
409- if record, ok := reuse[host]; ok {
410- learned[host] = record
411- continue
412- }
413- record, err := newRemoteRegistration(host)
414- if err != nil {
415- return nil, err
416- }
417- learned[host] = record
418- }
419- return learned, nil
420-}
421-
422-func (s *fileHostStore) saveLocked(state hostState) error {
423- pinned := make([]hostRecord, 0, len(state.pinned))
424- for _, record := range state.pinned {
425- pinned = append(pinned, encodeRecord(record))
426- }
427- learned := make([]hostRecord, 0, len(state.learned))
428- for _, record := range sortedRegistrations(state.learned) {
429- learned = append(learned, encodeRecord(record))
430- }
431- payload, err := json.Marshal(hostsFile{
432- Version: hostsFileVersion,
433- Pinned: pinned,
434- Learned: learned,
435- })
436- if err != nil {
437- return err
438- }
439- return atomicReplacePrivateFile(s.path, append(payload, '\n'))
440-}
441-
442-func encodeRecord(record domain.RemoteRegistration) hostRecord {
443- return hostRecord{
444- Endpoint: record.Endpoint,
445- Incarnation: hex.EncodeToString(record.Incarnation[:]),
446- Generation: uint64(record.Generation),
447- }
448-}
449-
450-func decodeRecord(record hostRecord) (domain.RemoteRegistration, error) {
451- if err := validateHostTarget(record.Endpoint); err != nil {
452- return domain.RemoteRegistration{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
453- }
454- raw, err := hex.DecodeString(record.Incarnation)
455- if err != nil || len(raw) != 16 {
456- return domain.RemoteRegistration{}, fmt.Errorf("remote host store: malformed hosts file: invalid incarnation for %q", record.Endpoint)
457- }
458- var incarnation [16]byte
459- copy(incarnation[:], raw)
460- registration := domain.RemoteRegistration{Endpoint: record.Endpoint, Incarnation: incarnation, Generation: domain.RemoteGeneration(record.Generation)}
461- if err := registration.Validate(); err != nil {
462- return domain.RemoteRegistration{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
463- }
464- return registration, nil
465-}
466-
467-func atomicReplacePrivateFile(path string, payload []byte) error {
468- dir := filepath.Dir(path)
469- if err := safedir.EnsurePrivate(dir); err != nil {
470- return err
471- }
472- tmp, err := os.CreateTemp(dir, ".vev-*.tmp")
473- if err != nil {
474- return err
475- }
476- tmpName := tmp.Name()
477- committed := false
478- defer func() {
479- if !committed {
480- _ = tmp.Close()
481- _ = os.Remove(tmpName)
482- }
483- }()
484- if err := tmp.Chmod(0o600); err != nil {
485- return err
486- }
487- if _, err := tmp.Write(payload); err != nil {
488- return err
489- }
490- if err := tmp.Sync(); err != nil {
491- return err
492- }
493- if err := tmp.Close(); err != nil {
494- return err
495- }
496- if err := os.Rename(tmpName, path); err != nil {
497- return err
498- }
499- committed = true
500- return syncDir(dir)
501-}
502-
503-func validatePinnedRecords(records []hostRecord) ([]domain.RemoteRegistration, error) {
504- pinned := make([]domain.RemoteRegistration, 0, len(records))
505- seen := make(map[string]struct{}, len(records))
506- for _, record := range records {
507- registration, err := decodeRecord(record)
508- if err != nil {
509- return nil, err
510- }
511- if _, dup := seen[registration.Endpoint]; dup {
512- return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", registration.Endpoint)
513- }
514- seen[registration.Endpoint] = struct{}{}
515- pinned = append(pinned, registration)
516- }
517- return pinned, nil
518-}
519-
520-func validateLearnedRecords(records []hostRecord) (map[string]domain.RemoteRegistration, error) {
521- learned := make(map[string]domain.RemoteRegistration, len(records))
522- for _, record := range records {
523- registration, err := decodeRecord(record)
524- if err != nil {
525- return nil, err
526- }
527- if _, dup := learned[registration.Endpoint]; dup {
528- return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", registration.Endpoint)
529- }
530- learned[registration.Endpoint] = registration
531- }
532- return learned, nil
533-}
534-
535-func validateHostTarget(target string) error {
536- if !utf8.ValidString(target) {
537- return fmt.Errorf("remote host target is not valid UTF-8")
538- }
539- return domain.ValidateRemoteHostTarget(target)
540-}
541-
542-func findRegistration(registrations []domain.RemoteRegistration, target string) (domain.RemoteRegistration, bool) {
543- for _, record := range registrations {
544- if record.Endpoint == target {
545- return record, true
546- }
547- }
548- return domain.RemoteRegistration{}, false
549-}
550-
551-func removeRegistration(registrations []domain.RemoteRegistration, target string) ([]domain.RemoteRegistration, bool) {
552- out := make([]domain.RemoteRegistration, 0, len(registrations))
553- changed := false
554- for _, record := range registrations {
555- if record.Endpoint == target {
556- changed = true
557- continue
558- }
559- out = append(out, record)
560- }
561- return out, changed
562-}
563-
564-func sortedRegistrations(registrations map[string]domain.RemoteRegistration) []domain.RemoteRegistration {
565- out := make([]domain.RemoteRegistration, 0, len(registrations))
566- for _, record := range registrations {
567- out = append(out, record)
568- }
569- sort.Slice(out, func(i, j int) bool { return out[i].Endpoint < out[j].Endpoint })
570- return out
571-}
572-
573-func syncDir(dir string) error {
574- file, err := os.Open(dir)
575- if err != nil {
576- return err
577- }
578- return errors.Join(file.Sync(), file.Close())
579-}
D internal/adapters/remote/host_registry_test.go +0 -901
@@ -1,901 +0,0 @@
1-package remote
2-
3-import (
4- "encoding/json"
5- "os"
6- "os/exec"
7- "path/filepath"
8- "slices"
9- "sort"
10- "strconv"
11- "strings"
12- "sync"
13- "testing"
14-
15- "github.com/bnema/vev/internal/domain"
16-)
17-
18-func TestHostStorePath(t *testing.T) {
19- stateDir := filepath.Join(t.TempDir(), "state")
20- want := filepath.Join(stateDir, "hosts.json")
21- if got := HostStorePath(stateDir); got != want {
22- t.Fatalf("HostStorePath(%q) = %q, want %q", stateDir, got, want)
23- }
24-}
25-
26-func TestNewRemoteRegistrationSamplesUniqueIncarnations(t *testing.T) {
27- first, err := newRemoteRegistration("user@arch")
28- if err != nil {
29- t.Fatalf("newRemoteRegistration: %v", err)
30- }
31- second, err := newRemoteRegistration("user@arch")
32- if err != nil {
33- t.Fatalf("newRemoteRegistration: %v", err)
34- }
35- if first.Incarnation == second.Incarnation {
36- t.Fatal("incarnations must be unique per registration")
37- }
38- if _, err := newRemoteRegistration("not a host"); err == nil {
39- t.Fatal("invalid endpoint must be rejected")
40- }
41-}
42-
43-func TestHostStoreCrossProcessHelper(t *testing.T) {
44- if os.Getenv("VEV_HOST_STORE_HELPER") != "1" {
45- t.Skip("helper process")
46- }
47-
48- path := os.Getenv("VEV_HOST_STORE_PATH")
49- hosts := strings.Split(os.Getenv("VEV_HOST_STORE_HOSTS"), ",")
50- store := NewFileHostStore(path)
51- for _, host := range hosts {
52- if host == "" {
53- continue
54- }
55- if err := store.Remember(host); err != nil {
56- t.Fatalf("Remember(%q) error = %v", host, err)
57- }
58- }
59-}
60-
61-func TestHostStoreCrossProcessConcurrentRemember(t *testing.T) {
62- path := filepath.Join(t.TempDir(), "vev", "hosts.json")
63- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
64- t.Fatalf("mkdir: %v", err)
65- }
66-
67- left := make([]string, 0, 40)
68- right := make([]string, 0, 40)
69- for i := 0; i < 40; i++ {
70- left = append(left, "left-"+strconv.Itoa(i))
71- right = append(right, "right-"+strconv.Itoa(i))
72- }
73-
74- startHelper := func(hosts []string) *exec.Cmd {
75- cmd := exec.Command(os.Args[0], "-test.run=^TestHostStoreCrossProcessHelper$")
76- cmd.Env = append(os.Environ(),
77- "VEV_HOST_STORE_HELPER=1",
78- "VEV_HOST_STORE_PATH="+path,
79- "VEV_HOST_STORE_HOSTS="+strings.Join(hosts, ","),
80- )
81- cmd.Stderr = os.Stderr
82- return cmd
83- }
84-
85- leftCmd := startHelper(left)
86- rightCmd := startHelper(right)
87- if err := leftCmd.Start(); err != nil {
88- t.Fatalf("start left helper: %v", err)
89- }
90- if err := rightCmd.Start(); err != nil {
91- t.Fatalf("start right helper: %v", err)
92- }
93- if err := leftCmd.Wait(); err != nil {
94- t.Fatalf("left helper: %v", err)
95- }
96- if err := rightCmd.Wait(); err != nil {
97- t.Fatalf("right helper: %v", err)
98- }
99-
100- _, learned, err := NewFileHostStore(path).Hosts()
101- if err != nil {
102- t.Fatalf("Hosts() error = %v", err)
103- }
104- want := append(append([]string{}, left...), right...)
105- sort.Strings(want)
106- if !slices.Equal(registrationEndpoints(learned), want) {
107- t.Fatalf("learned len=%d want %d; missing merges under cross-process writers", len(learned), len(want))
108- }
109-}
110-
111-func registrationEndpoints(registrations []domain.RemoteRegistration) []string {
112- endpoints := make([]string, 0, len(registrations))
113- for _, record := range registrations {
114- endpoints = append(endpoints, record.Endpoint)
115- }
116- return endpoints
117-}
118-
119-func requireValidRegistrations(t *testing.T, registrations []domain.RemoteRegistration) {
120- t.Helper()
121- for _, record := range registrations {
122- if err := record.Validate(); err != nil {
123- t.Fatalf("registration %+v invalid: %v", record, err)
124- }
125- }
126-}
127-
128-func TestHostStore(t *testing.T) {
129- t.Parallel()
130-
131- storePath := func(t *testing.T) string {
132- t.Helper()
133- dir := filepath.Join(t.TempDir(), "vev")
134- return filepath.Join(dir, "hosts.json")
135- }
136-
137- writeHosts := func(t *testing.T, path string, raw []byte) {
138- t.Helper()
139- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
140- t.Fatalf("mkdir: %v", err)
141- }
142- if err := os.WriteFile(path, raw, 0o600); err != nil {
143- t.Fatalf("write hosts: %v", err)
144- }
145- }
146-
147- t.Run("missing file returns empty lists", func(t *testing.T) {
148- t.Parallel()
149- path := storePath(t)
150- store := NewFileHostStore(path)
151-
152- pinned, learned, err := store.Hosts()
153- if err != nil {
154- t.Fatalf("Hosts() error = %v", err)
155- }
156- if len(pinned) != 0 || len(learned) != 0 {
157- t.Fatalf("Hosts() = pinned %v learned %v, want empty", pinned, learned)
158- }
159- })
160-
161- t.Run("pinned order preserved and learned sorted with private perms", func(t *testing.T) {
162- t.Parallel()
163- path := storePath(t)
164- store := NewFileHostStore(path)
165-
166- if err := store.AddPinned("zebra"); err != nil {
167- t.Fatalf("AddPinned(zebra) error = %v", err)
168- }
169- if err := store.AddPinned("arch"); err != nil {
170- t.Fatalf("AddPinned(arch) error = %v", err)
171- }
172- if err := store.Remember("mule"); err != nil {
173- t.Fatalf("Remember(mule) error = %v", err)
174- }
175- if err := store.Remember("beta"); err != nil {
176- t.Fatalf("Remember(beta) error = %v", err)
177- }
178-
179- pinned, learned, err := store.Hosts()
180- if err != nil {
181- t.Fatalf("Hosts() error = %v", err)
182- }
183- wantPinned := []string{"zebra", "arch"}
184- wantLearned := []string{"beta", "mule"}
185- if !slices.Equal(registrationEndpoints(pinned), wantPinned) {
186- t.Fatalf("pinned = %v, want %v", pinned, wantPinned)
187- }
188- if !slices.Equal(registrationEndpoints(learned), wantLearned) {
189- t.Fatalf("learned = %v, want %v", learned, wantLearned)
190- }
191- requireValidRegistrations(t, pinned)
192- requireValidRegistrations(t, learned)
193-
194- info, err := os.Stat(path)
195- if err != nil {
196- t.Fatalf("stat store: %v", err)
197- }
198- if perm := info.Mode().Perm(); perm != 0o600 {
199- t.Fatalf("store permissions = %04o, want 0600", perm)
200- }
201- dirInfo, err := os.Lstat(filepath.Dir(path))
202- if err != nil {
203- t.Fatalf("stat parent: %v", err)
204- }
205- if perm := dirInfo.Mode().Perm(); perm != 0o700 {
206- t.Fatalf("parent permissions = %04o, want 0700", perm)
207- }
208-
209- raw, err := os.ReadFile(path)
210- if err != nil {
211- t.Fatalf("read store: %v", err)
212- }
213- var decoded hostsFile
214- if err := json.Unmarshal(raw, &decoded); err != nil {
215- t.Fatalf("decode store: %v", err)
216- }
217- if decoded.Version != 3 {
218- t.Fatalf("version = %d, want 3", decoded.Version)
219- }
220- stored := make([]domain.RemoteRegistration, 0, len(decoded.Pinned)+len(decoded.Learned))
221- for _, record := range decoded.Pinned {
222- registration, err := decodeRecord(record)
223- if err != nil {
224- t.Fatalf("decode stored pinned: %v", err)
225- }
226- stored = append(stored, registration)
227- }
228- for _, record := range decoded.Learned {
229- registration, err := decodeRecord(record)
230- if err != nil {
231- t.Fatalf("decode stored learned: %v", err)
232- }
233- stored = append(stored, registration)
234- }
235- if !slices.Equal(registrationEndpoints(stored[:len(decoded.Pinned)]), wantPinned) {
236- t.Fatalf("stored pinned = %v, want %v", decoded.Pinned, wantPinned)
237- }
238- if !slices.Equal(registrationEndpoints(stored[len(decoded.Pinned):]), wantLearned) {
239- t.Fatalf("stored learned = %v, want %v", decoded.Learned, wantLearned)
240- }
241- requireValidRegistrations(t, stored)
242- })
243-
244- t.Run("overlap allowed across pinned and learned", func(t *testing.T) {
245- t.Parallel()
246- path := storePath(t)
247- store := NewFileHostStore(path)
248-
249- if err := store.AddPinned("arch"); err != nil {
250- t.Fatalf("AddPinned(arch) error = %v", err)
251- }
252- if err := store.Remember("arch"); err != nil {
253- t.Fatalf("Remember(arch) error = %v", err)
254- }
255- pinned, learned, err := store.Hosts()
256- if err != nil {
257- t.Fatalf("Hosts() error = %v", err)
258- }
259- if !slices.Equal(registrationEndpoints(pinned), []string{"arch"}) {
260- t.Fatalf("pinned = %v, want [arch]", pinned)
261- }
262- if !slices.Equal(registrationEndpoints(learned), []string{"arch"}) {
263- t.Fatalf("learned = %v, want [arch]", learned)
264- }
265- // Overlap shares one registration: pinning a learned host and
266- // remembering a pinned host retain the incarnation.
267- if !pinned[0].Equal(learned[0]) {
268- t.Fatalf("overlapping registrations differ: pinned %+v learned %+v", pinned[0], learned[0])
269- }
270- })
271-
272- t.Run("idempotent mutations and remove from both sources", func(t *testing.T) {
273- t.Parallel()
274- path := storePath(t)
275- store := NewFileHostStore(path)
276-
277- if err := store.AddPinned("arch"); err != nil {
278- t.Fatalf("AddPinned(arch) error = %v", err)
279- }
280- if err := store.AddPinned("arch"); err != nil {
281- t.Fatalf("AddPinned(arch) again error = %v", err)
282- }
283- if err := store.Remember("mule"); err != nil {
284- t.Fatalf("Remember(mule) error = %v", err)
285- }
286- if err := store.Remember("mule"); err != nil {
287- t.Fatalf("Remember(mule) again error = %v", err)
288- }
289- if err := store.Remember("arch"); err != nil {
290- t.Fatalf("Remember(arch) error = %v", err)
291- }
292-
293- if err := store.RemovePinned("missing"); err != nil {
294- t.Fatalf("RemovePinned(missing) error = %v", err)
295- }
296- if err := store.Forget("missing"); err != nil {
297- t.Fatalf("Forget(missing) error = %v", err)
298- }
299- if deleted, err := store.Remove("missing"); err != nil {
300- t.Fatalf("Remove(missing) error = %v", err)
301- } else if deleted {
302- t.Fatal("Remove(missing) deleted = true, want false")
303- }
304-
305- if deleted, err := store.Remove("arch"); err != nil {
306- t.Fatalf("Remove(arch) error = %v", err)
307- } else if !deleted {
308- t.Fatal("Remove(arch) deleted = false, want true")
309- }
310- pinned, learned, err := store.Hosts()
311- if err != nil {
312- t.Fatalf("Hosts() error = %v", err)
313- }
314- if len(pinned) != 0 {
315- t.Fatalf("pinned after Remove(arch) = %v, want empty", pinned)
316- }
317- if !slices.Equal(registrationEndpoints(learned), []string{"mule"}) {
318- t.Fatalf("learned after Remove(arch) = %v, want [mule]", learned)
319- }
320-
321- if err := store.Forget("mule"); err != nil {
322- t.Fatalf("Forget(mule) error = %v", err)
323- }
324- if err := store.Forget("mule"); err != nil {
325- t.Fatalf("Forget(mule) again error = %v", err)
326- }
327- _, learned, err = store.Hosts()
328- if err != nil {
329- t.Fatalf("Hosts() after forget error = %v", err)
330- }
331- if len(learned) != 0 {
332- t.Fatalf("learned = %v, want empty", learned)
333- }
334- })
335-
336- t.Run("malformed file is preserved and fails closed", func(t *testing.T) {
337- t.Parallel()
338- path := storePath(t)
339- malformed := []byte(`{"version":2,"pinned":`)
340- writeHosts(t, path, malformed)
341-
342- store := NewFileHostStore(path)
343- if _, _, err := store.Hosts(); err == nil {
344- t.Fatal("Hosts() error = nil, want malformed error")
345- }
346- if err := store.AddPinned("arch"); err == nil {
347- t.Fatal("AddPinned() error = nil, want malformed error")
348- }
349- if err := store.Remember("arch"); err == nil {
350- t.Fatal("Remember() error = nil, want malformed error")
351- }
352- if _, err := store.Remove("arch"); err == nil {
353- t.Fatal("Remove() error = nil, want malformed error")
354- }
355-
356- raw, err := os.ReadFile(path)
357- if err != nil {
358- t.Fatalf("read preserved file: %v", err)
359- }
360- if string(raw) != string(malformed) {
361- t.Fatalf("malformed file changed: got %q, want %q", raw, malformed)
362- }
363- })
364-
365- t.Run("legacy version 1 migrates on read with backup", func(t *testing.T) {
366- t.Parallel()
367- cases := []struct {
368- name string
369- raw string
370- wantLearned []string
371- }{
372- {name: "empty", raw: `{"version":1,"hosts":[]}`, wantLearned: []string{}},
373- {name: "hosts", raw: `{"version":1,"hosts":["mule","arch"]}`, wantLearned: []string{"arch", "mule"}},
374- }
375- for _, tc := range cases {
376- t.Run(tc.name, func(t *testing.T) {
377- t.Parallel()
378- path := storePath(t)
379- writeHosts(t, path, []byte(tc.raw))
380- store := NewFileHostStore(path)
381-
382- pinned, learned, err := store.Hosts()
383- if err != nil {
384- t.Fatalf("Hosts() error = %v", err)
385- }
386- if len(pinned) != 0 || !slices.Equal(registrationEndpoints(learned), tc.wantLearned) {
387- t.Fatalf("Hosts() = pinned %v learned %v, want pinned empty learned %v", pinned, learned, tc.wantLearned)
388- }
389- requireValidRegistrations(t, learned)
390- // The read migrated: the file is now version 3 and the
391- // pre-migration bytes are preserved privately.
392- raw, err := os.ReadFile(path)
393- if err != nil {
394- t.Fatalf("read migrated store: %v", err)
395- }
396- var decoded hostsFile
397- if err := json.Unmarshal(raw, &decoded); err != nil {
398- t.Fatalf("decode migrated store: %v", err)
399- }
400- if decoded.Version != hostsFileVersion {
401- t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion)
402- }
403- backup, err := os.ReadFile(path + hostsBackupSuffix)
404- if err != nil {
405- t.Fatalf("read migration backup: %v", err)
406- }
407- if string(backup) != tc.raw {
408- t.Fatalf("backup = %q, want pre-migration %q", backup, tc.raw)
409- }
410- info, err := os.Stat(path + hostsBackupSuffix)
411- if err != nil {
412- t.Fatalf("stat backup: %v", err)
413- }
414- if perm := info.Mode().Perm(); perm != 0o600 {
415- t.Fatalf("backup permissions = %04o, want 0600", perm)
416- }
417- // A repeated load retains the migrated identity.
418- againPinned, againLearned, err := store.Hosts()
419- if err != nil {
420- t.Fatalf("Hosts() again error = %v", err)
421- }
422- if len(againPinned) != 0 || len(againLearned) != len(learned) {
423- t.Fatalf("Hosts() again = pinned %v learned %v", againPinned, againLearned)
424- }
425- for i := range learned {
426- if !learned[i].Equal(againLearned[i]) {
427- t.Fatalf("identity not retained: %+v vs %+v", learned[i], againLearned[i])
428- }
429- }
430- })
431- }
432- })
433-
434- t.Run("legacy version 2 migrates on read preserving order", func(t *testing.T) {
435- t.Parallel()
436- path := storePath(t)
437- legacy := `{"version":2,"pinned":["zebra","arch"],"learned":["mule","beta"]}`
438- writeHosts(t, path, []byte(legacy))
439- store := NewFileHostStore(path)
440-
441- pinned, learned, err := store.Hosts()
442- if err != nil {
443- t.Fatalf("Hosts() error = %v", err)
444- }
445- if !slices.Equal(registrationEndpoints(pinned), []string{"zebra", "arch"}) {
446- t.Fatalf("pinned = %v, want [zebra arch]", pinned)
447- }
448- if !slices.Equal(registrationEndpoints(learned), []string{"beta", "mule"}) {
449- t.Fatalf("learned = %v, want [beta mule]", learned)
450- }
451- requireValidRegistrations(t, pinned)
452- requireValidRegistrations(t, learned)
453-
454- raw, err := os.ReadFile(path)
455- if err != nil {
456- t.Fatalf("read migrated store: %v", err)
457- }
458- var decoded hostsFile
459- if err := json.Unmarshal(raw, &decoded); err != nil {
460- t.Fatalf("decode migrated store: %v", err)
461- }
462- if decoded.Version != hostsFileVersion {
463- t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion)
464- }
465- backup, err := os.ReadFile(path + hostsBackupSuffix)
466- if err != nil {
467- t.Fatalf("read migration backup: %v", err)
468- }
469- if string(backup) != legacy {
470- t.Fatalf("backup = %q, want pre-migration %q", backup, legacy)
471- }
472- })
473-
474- t.Run("legacy version 2 overlapping membership shares one registration", func(t *testing.T) {
475- t.Parallel()
476- path := storePath(t)
477- legacy := `{"version":2,"pinned":["arch"],"learned":["arch","mule"]}`
478- writeHosts(t, path, []byte(legacy))
479- store := NewFileHostStore(path)
480-
481- pinned, learned, err := store.Hosts()
482- if err != nil {
483- t.Fatalf("Hosts() error = %v", err)
484- }
485- requireValidRegistrations(t, pinned)
486- requireValidRegistrations(t, learned)
487- if len(pinned) != 1 || len(learned) != 2 {
488- t.Fatalf("pinned = %v, learned = %v, want 1 pinned and 2 learned", pinned, learned)
489- }
490- if pinned[0] != learned[0] {
491- t.Fatalf("overlapping endpoint has two incarnations: pinned %+v vs learned %+v", pinned[0], learned[0])
492- }
493- // Removing only the pin must not surface a second identity
494- // for an endpoint that was never fully removed.
495- if err := store.RemovePinned("arch"); err != nil {
496- t.Fatalf("RemovePinned(arch) error = %v", err)
497- }
498- _, againLearned, err := store.Hosts()
499- if err != nil {
500- t.Fatalf("Hosts() error = %v", err)
501- }
502- if len(againLearned) != 2 || againLearned[0] != pinned[0] {
503- t.Fatalf("identity not retained after unpin: %+v vs %+v", againLearned, pinned)
504- }
505- })
506-
507- t.Run("conflicting version 3 overlap fails closed", func(t *testing.T) {
508- t.Parallel()
509- path := storePath(t)
510- conflict := `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"01000000000000000000000000000000","generation":1}],"learned":[{"endpoint":"arch","incarnation":"02000000000000000000000000000000","generation":1}]}`
511- writeHosts(t, path, []byte(conflict))
512- store := NewFileHostStore(path)
513-
514- if _, _, err := store.Hosts(); err == nil {
515- t.Fatal("Hosts() error = nil, want conflicting identity rejection")
516- }
517- raw, err := os.ReadFile(path)
518- if err != nil {
519- t.Fatalf("read store: %v", err)
520- }
521- if string(raw) != conflict {
522- t.Fatalf("rejected store changed: got %q, want %q", raw, conflict)
523- }
524- })
525-
526- t.Run("remove and re-add creates a new incarnation", func(t *testing.T) {
527- t.Parallel()
528- path := storePath(t)
529- store := NewFileHostStore(path)
530- if err := store.Remember("arch"); err != nil {
531- t.Fatalf("Remember(arch) error = %v", err)
532- }
533- _, learned, err := store.Hosts()
534- if err != nil {
535- t.Fatalf("Hosts() error = %v", err)
536- }
537- first := learned[0]
538-
539- if deleted, err := store.Remove("arch"); err != nil || !deleted {
540- t.Fatalf("Remove(arch) = %t, %v", deleted, err)
541- }
542- if err := store.Remember("arch"); err != nil {
543- t.Fatalf("Remember(arch) again error = %v", err)
544- }
545- _, relearned, err := store.Hosts()
546- if err != nil {
547- t.Fatalf("Hosts() again error = %v", err)
548- }
549- if len(relearned) != 1 {
550- t.Fatalf("Hosts() again = %v, want one registration", relearned)
551- }
552- if relearned[0].Equal(first) {
553- t.Fatalf("re-added registration %+v must not equal removed %+v", relearned[0], first)
554- }
555- if relearned[0].Incarnation == first.Incarnation {
556- t.Fatalf("re-added incarnation reused after full removal")
557- }
558- })
559-
560- t.Run("schema failures fail closed and preserve file", func(t *testing.T) {
561- t.Parallel()
562- cases := []struct {
563- name string
564- raw string
565- wantError string
566- }{
567- {name: "unsupported version", raw: `{"version":99,"pinned":[],"learned":[]}`, wantError: "unsupported hosts file version 99"},
568- {name: "null pinned", raw: `{"version":2,"pinned":null,"learned":[]}`, wantError: "malformed hosts file"},
569- {name: "null learned", raw: `{"version":2,"pinned":[],"learned":null}`, wantError: "malformed hosts file"},
570- {name: "duplicate pinned", raw: `{"version":2,"pinned":["arch","arch"],"learned":[]}`, wantError: "malformed hosts file"},
571- {name: "duplicate learned", raw: `{"version":2,"pinned":[],"learned":["mule","mule"]}`, wantError: "malformed hosts file"},
572- {name: "invalid target", raw: `{"version":2,"pinned":[" arch"],"learned":[]}`, wantError: "malformed hosts file"},
573- {name: "invalid utf8", raw: "{\"version\":2,\"pinned\":[\"\xff\"],\"learned\":[]}", wantError: "malformed hosts file"},
574- {name: "legacy missing hosts", raw: `{"version":1}`, wantError: "malformed hosts file: missing hosts"},
575- {name: "legacy null hosts", raw: `{"version":1,"hosts":null}`, wantError: "malformed hosts file: missing hosts"},
576- {name: "legacy duplicate hosts", raw: `{"version":1,"hosts":["arch","arch"]}`, wantError: "malformed hosts file: duplicate learned host"},
577- {name: "legacy invalid target", raw: `{"version":1,"hosts":[" arch"]}`, wantError: "malformed hosts file"},
578- {name: "legacy invalid utf8", raw: "{\"version\":1,\"hosts\":[\"\xff\"]}", wantError: "malformed hosts file"},
579- {name: "v3 string pinned is malformed", raw: `{"version":3,"pinned":["arch"],"learned":[]}`, wantError: "malformed hosts file"},
580- {name: "v3 missing incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","generation":1}],"learned":[]}`, wantError: "malformed hosts file"},
581- {name: "v3 malformed incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"zzzz","generation":1}],"learned":[]}`, wantError: "malformed hosts file"},
582- {name: "v3 short incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"0123","generation":1}],"learned":[]}`, wantError: "malformed hosts file"},
583- {name: "v3 zero incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"00000000000000000000000000000000","generation":1}],"learned":[]}`, wantError: "malformed hosts file"},
584- {name: "v3 zero generation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"01000000000000000000000000000000","generation":0}],"learned":[]}`, wantError: "malformed hosts file"},
585- {name: "v3 duplicate pinned", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"01000000000000000000000000000000","generation":1},{"endpoint":"arch","incarnation":"02000000000000000000000000000000","generation":1}],"learned":[]}`, wantError: "duplicate pinned host"},
586- }
587- for _, tc := range cases {
588- t.Run(tc.name, func(t *testing.T) {
589- t.Parallel()
590- path := storePath(t)
591- writeHosts(t, path, []byte(tc.raw))
592-
593- store := NewFileHostStore(path)
594- if _, _, err := store.Hosts(); err == nil {
595- t.Fatal("Hosts() error = nil, want schema error")
596- } else if !strings.Contains(err.Error(), tc.wantError) {
597- t.Fatalf("Hosts() error = %q, want substring %q", err, tc.wantError)
598- }
599- if err := store.Remember("arch"); err == nil {
600- t.Fatal("Remember() error = nil, want schema error")
601- }
602- raw, err := os.ReadFile(path)
603- if err != nil {
604- t.Fatalf("read preserved file: %v", err)
605- }
606- if string(raw) != tc.raw {
607- t.Fatalf("file changed: got %q, want %q", raw, tc.raw)
608- }
609- })
610- }
611- })
612-
613- t.Run("reads v3 without rewriting until mutation", func(t *testing.T) {
614- t.Parallel()
615- path := storePath(t)
616- seeded := `{"version":3,"pinned":[{"endpoint":"zebra","incarnation":"01000000000000000000000000000000","generation":1},{"endpoint":"arch","incarnation":"02000000000000000000000000000000","generation":1}],"learned":[{"endpoint":"mule","incarnation":"03000000000000000000000000000000","generation":1},{"endpoint":"beta","incarnation":"04000000000000000000000000000000","generation":1}]}` + "\n"
617- writeHosts(t, path, []byte(seeded))
618-
619- store := NewFileHostStore(path)
620- pinned, learned, err := store.Hosts()
621- if err != nil {
622- t.Fatalf("Hosts() error = %v", err)
623- }
624- if !slices.Equal(registrationEndpoints(pinned), []string{"zebra", "arch"}) {
625- t.Fatalf("pinned = %v, want [zebra arch]", pinned)
626- }
627- if !slices.Equal(registrationEndpoints(learned), []string{"beta", "mule"}) {
628- t.Fatalf("learned = %v, want [beta mule]", learned)
629- }
630- // Identity survives the read; incarnations are stable.
631- beforePinned, beforeLearned := pinned, learned
632- raw, err := os.ReadFile(path)
633- if err != nil {
634- t.Fatalf("read store: %v", err)
635- }
636- if string(raw) != seeded {
637- t.Fatalf("read-only Hosts rewrote file: got %q", raw)
638- }
639-
640- if err := store.Remember("gamma"); err != nil {
641- t.Fatalf("Remember(gamma) error = %v", err)
642- }
643- raw, err = os.ReadFile(path)
644- if err != nil {
645- t.Fatalf("read after mutation: %v", err)
646- }
647- var decoded hostsFile
648- if err := json.Unmarshal(raw, &decoded); err != nil {
649- t.Fatalf("decode: %v", err)
650- }
651- afterPinned, err := validatePinnedRecords(decoded.Pinned)
652- if err != nil {
653- t.Fatalf("decode pinned: %v", err)
654- }
655- afterLearned, err := validateLearnedRecords(decoded.Learned)
656- if err != nil {
657- t.Fatalf("decode learned: %v", err)
658- }
659- if !slices.Equal(registrationEndpoints(afterPinned), []string{"zebra", "arch"}) {
660- t.Fatalf("pinned after mutation = %v", decoded.Pinned)
661- }
662- if !slices.Equal(registrationEndpoints(sortedRegistrations(afterLearned)), []string{"beta", "gamma", "mule"}) {
663- t.Fatalf("learned after mutation = %v", decoded.Learned)
664- }
665- // Unrelated registrations keep their incarnations across mutation.
666- for i := range beforePinned {
667- if !beforePinned[i].Equal(afterPinned[i]) {
668- t.Fatalf("pinned identity changed: %+v vs %+v", beforePinned[i], afterPinned[i])
669- }
670- }
671- for endpoint, before := range map[string]domain.RemoteRegistration{
672- "mule": beforeLearned[1], "beta": beforeLearned[0],
673- } {
674- after, ok := afterLearned[endpoint]
675- if !ok || !before.Equal(after) {
676- t.Fatalf("learned identity changed for %q: %+v vs %+v", endpoint, before, after)
677- }
678- }
679- })
680-
681- t.Run("rejects invalid targets", func(t *testing.T) {
682- t.Parallel()
683- path := storePath(t)
684- store := NewFileHostStore(path)
685-
686- for _, target := range []string{"", " arch", "arch ", "ar ch", "arch\n", string([]byte{0xff})} {
687- if err := store.AddPinned(target); err == nil {
688- t.Fatalf("AddPinned(%q) error = nil, want validation error", target)
689- }
690- if err := store.Remember(target); err == nil {
691- t.Fatalf("Remember(%q) error = nil, want validation error", target)
692- }
693- if _, err := store.Remove(target); err == nil {
694- t.Fatalf("Remove(%q) error = nil, want validation error", target)
695- }
696- }
697- })
698-
699- t.Run("rejects symlink parent", func(t *testing.T) {
700- t.Parallel()
701- root := t.TempDir()
702- realDir := filepath.Join(root, "real")
703- if err := os.MkdirAll(realDir, 0o700); err != nil {
704- t.Fatalf("mkdir real: %v", err)
705- }
706- linkDir := filepath.Join(root, "link")
707- if err := os.Symlink(realDir, linkDir); err != nil {
708- t.Fatalf("symlink parent: %v", err)
709- }
710- path := filepath.Join(linkDir, "hosts.json")
711- store := NewFileHostStore(path)
712-
713- if err := store.Remember("arch"); err == nil {
714- t.Fatal("Remember() error = nil, want symlink parent rejection")
715- }
716- })
717-
718- t.Run("rejects non-private parent permissions", func(t *testing.T) {
719- t.Parallel()
720- path := storePath(t)
721- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
722- t.Fatalf("mkdir: %v", err)
723- }
724- store := NewFileHostStore(path)
725- if err := store.Remember("arch"); err == nil {
726- t.Fatal("Remember() error = nil, want permission rejection")
727- }
728- })
729-
730- t.Run("atomic replacement leaves no temp files", func(t *testing.T) {
731- t.Parallel()
732- path := storePath(t)
733- store := NewFileHostStore(path)
734- if err := store.AddPinned("arch"); err != nil {
735- t.Fatalf("AddPinned() error = %v", err)
736- }
737- if err := store.Remember("mule"); err != nil {
738- t.Fatalf("Remember() error = %v", err)
739- }
740-
741- entries, err := os.ReadDir(filepath.Dir(path))
742- if err != nil {
743- t.Fatalf("readdir: %v", err)
744- }
745- base := filepath.Base(path)
746- for _, entry := range entries {
747- name := entry.Name()
748- if name == base || strings.HasSuffix(name, ".lock") {
749- continue
750- }
751- if strings.Contains(name, ".tmp") {
752- t.Fatalf("leftover temp file %q", name)
753- }
754- t.Fatalf("unexpected leftover entry %q", name)
755- }
756- })
757-
758- t.Run("hardens existing file and lock permissions to 0600", func(t *testing.T) {
759- t.Parallel()
760- path := storePath(t)
761- dir := filepath.Dir(path)
762- if err := os.MkdirAll(dir, 0o700); err != nil {
763- t.Fatalf("mkdir: %v", err)
764- }
765- if err := os.WriteFile(path, []byte(`{"version":2,"pinned":[],"learned":[]}`), 0o644); err != nil {
766- t.Fatalf("seed hosts: %v", err)
767- }
768- lockPath := path + ".lock"
769- if err := os.WriteFile(lockPath, nil, 0o644); err != nil {
770- t.Fatalf("seed lock: %v", err)
771- }
772- info, err := os.Stat(lockPath)
773- if err != nil {
774- t.Fatalf("stat seeded lock: %v", err)
775- }
776- if perm := info.Mode().Perm(); perm != 0o644 {
777- t.Fatalf("seeded lock permissions = %04o, want 0644", perm)
778- }
779-
780- store := NewFileHostStore(path)
781- if err := store.Remember("arch"); err != nil {
782- t.Fatalf("Remember() error = %v", err)
783- }
784-
785- info, err = os.Stat(path)
786- if err != nil {
787- t.Fatalf("stat hosts: %v", err)
788- }
789- if perm := info.Mode().Perm(); perm != 0o600 {
790- t.Fatalf("hosts permissions = %04o, want 0600", perm)
791- }
792- info, err = os.Stat(lockPath)
793- if err != nil {
794- t.Fatalf("stat lock: %v", err)
795- }
796- if perm := info.Mode().Perm(); perm != 0o600 {
797- t.Fatalf("lock permissions = %04o, want 0600", perm)
798- }
799- })
800-
801- t.Run("failed mutation rolls back without partial write", func(t *testing.T) {
802- t.Parallel()
803- path := storePath(t)
804- store := NewFileHostStore(path)
805- if err := store.AddPinned("arch"); err != nil {
806- t.Fatalf("AddPinned() error = %v", err)
807- }
808- if err := store.Remember("mule"); err != nil {
809- t.Fatalf("Remember() error = %v", err)
810- }
811- before, err := os.ReadFile(path)
812- if err != nil {
813- t.Fatalf("read before: %v", err)
814- }
815-
816- // Replace with malformed content while holding no store lock; next mutation must fail closed.
817- malformed := []byte(`{"version":2,"pinned":`)
818- if err := os.WriteFile(path, malformed, 0o600); err != nil {
819- t.Fatalf("inject malformed: %v", err)
820- }
821- if err := store.AddPinned("zebra"); err == nil {
822- t.Fatal("AddPinned() error = nil, want malformed error")
823- }
824- after, err := os.ReadFile(path)
825- if err != nil {
826- t.Fatalf("read after failed mutation: %v", err)
827- }
828- if string(after) != string(malformed) {
829- t.Fatalf("failed mutation changed file: got %q want %q (previous good was %q)", after, malformed, before)
830- }
831- })
832-
833- t.Run("concurrent independent stores lose no hosts", func(t *testing.T) {
834- t.Parallel()
835- path := storePath(t)
836-
837- left := make([]string, 0, 40)
838- right := make([]string, 0, 40)
839- for i := 0; i < 40; i++ {
840- left = append(left, "left-"+strconv.Itoa(i))
841- right = append(right, "right-"+strconv.Itoa(i))
842- }
843-
844- start := make(chan struct{})
845- var wg sync.WaitGroup
846- errs := make(chan error, 2)
847-
848- wg.Add(2)
849- go func() {
850- defer wg.Done()
851- store := NewFileHostStore(path)
852- <-start
853- for _, host := range left {
854- if err := store.Remember(host); err != nil {
855- errs <- err
856- return
857- }
858- }
859- }()
860- go func() {
861- defer wg.Done()
862- store := NewFileHostStore(path)
863- <-start
864- for _, host := range right {
865- if err := store.AddPinned(host); err != nil {
866- errs <- err
867- return
868- }
869- }
870- }()
871-
872- close(start)
873- wg.Wait()
874- close(errs)
875- for err := range errs {
876- t.Fatalf("concurrent mutation error = %v", err)
877- }
878-
879- pinned, learned, err := NewFileHostStore(path).Hosts()
880- if err != nil {
881- t.Fatalf("Hosts() error = %v", err)
882- }
883- wantLearned := append([]string{}, left...)
884- sort.Strings(wantLearned)
885- if !slices.Equal(registrationEndpoints(learned), wantLearned) {
886- t.Fatalf("learned len=%d want %d; missing merges under dual writers", len(learned), len(wantLearned))
887- }
888- if len(pinned) != len(right) {
889- t.Fatalf("pinned len=%d want %d", len(pinned), len(right))
890- }
891- pinnedSet := make(map[string]struct{}, len(pinned))
892- for _, record := range pinned {
893- pinnedSet[record.Endpoint] = struct{}{}
894- }
895- for _, host := range right {
896- if _, ok := pinnedSet[host]; !ok {
897- t.Fatalf("pinned missing %q", host)
898- }
899- }
900- })
901-}
D internal/adapters/remote/memory_catalog_cache.go +0 -85
@@ -1,85 +0,0 @@
1-package remote
2-
3-import (
4- "sync"
5-
6- appports "github.com/bnema/vev/internal/ports"
7- "github.com/bnema/vev/internal/protocol/catalogue"
8-)
9-
10-// SeededCatalogCache is a runner-local remote catalogue cache: it starts from
11-// the durable cache's entries and only ever writes in memory. A client monitor
12-// uses it so its own observations can never race, truncate, or overwrite the
13-// daemon's whole-directory snapshot file.
14-type SeededCatalogCache struct {
15- seed appports.RemoteCatalogCache
16-
17- mu sync.Mutex
18- loaded bool
19- entries []catalogue.RemoteCatalogCacheEntry
20- loadErr error
21-}
22-
23-var _ appports.RemoteCatalogCache = (*SeededCatalogCache)(nil)
24-
25-// NewSeededCatalogCache wraps the durable cache. Construction performs no I/O:
26-// the seed is read on the first Load.
27-func NewSeededCatalogCache(seed appports.RemoteCatalogCache) *SeededCatalogCache {
28- return &SeededCatalogCache{seed: seed}
29-}
30-
31-// Load returns the in-memory entries, reading the durable seed once.
32-func (c *SeededCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
33- if c == nil {
34- return nil, nil
35- }
36- c.mu.Lock()
37- defer c.mu.Unlock()
38- if !c.loaded {
39- c.loaded = true
40- if c.seed != nil {
41- entries, err := c.seed.Load()
42- if err != nil {
43- // A failed seed read is reported once and never retried: the
44- // client monitor must not hammer a broken cache file.
45- c.loadErr = err
46- } else {
47- c.entries = cloneCatalogEntries(entries)
48- }
49- }
50- }
51- if c.loadErr != nil {
52- return nil, c.loadErr
53- }
54- return cloneCatalogEntries(c.entries), nil
55-}
56-
57-// Store replaces the in-memory entries. Nothing reaches the durable file.
58-func (c *SeededCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
59- if c == nil {
60- return nil
61- }
62- c.mu.Lock()
63- defer c.mu.Unlock()
64- c.loaded = true
65- c.entries = cloneCatalogEntries(entries)
66- return nil
67-}
68-
69-// cloneCatalogEntries returns a defensive copy of the entries, including the
70-// nested session and tab slices the monitor publishes from.
71-func cloneCatalogEntries(entries []catalogue.RemoteCatalogCacheEntry) []catalogue.RemoteCatalogCacheEntry {
72- if entries == nil {
73- return nil
74- }
75- out := make([]catalogue.RemoteCatalogCacheEntry, len(entries))
76- for i, entry := range entries {
77- out[i] = entry
78- out[i].Sessions = make([]catalogue.RemoteCatalogSession, len(entry.Sessions))
79- for j, session := range entry.Sessions {
80- out[i].Sessions[j] = session
81- out[i].Sessions[j].Tabs = append([]catalogue.RemoteCatalogTab(nil), session.Tabs...)
82- }
83- }
84- return out
85-}
D internal/adapters/remote/preview.go +0 -109
@@ -1,109 +0,0 @@
1-package remote
2-
3-import (
4- "context"
5- "encoding/base64"
6- "errors"
7- "fmt"
8- "os/exec"
9- "time"
10-
11- "github.com/bnema/vev/internal/adapters/sessionwire"
12- "github.com/bnema/vev/internal/adapters/sshstdio"
13- "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/ports"
15- "github.com/bnema/vev/internal/protocol"
16-)
17-
18-const (
19- remotePreviewCommandTimeout = 5 * time.Second
20- remotePreviewSSHConnectTimeout = 5 * time.Second
21- remotePreviewWaitDelay = 500 * time.Millisecond
22- remotePreviewMaxDiagnostic = 4 << 10
23-)
24-
25-var (
26- errRemotePreviewSSH = errors.New("remote preview: ssh command failed")
27- errRemotePreviewTooLarge = errors.New("remote preview: output exceeds size limit")
28- errRemotePreviewIdentity = errors.New("remote preview: response identity mismatch")
29-)
30-
31-// PreviewClient invokes the hidden remote preview command over SSH. The
32-// command receives only a base64url request and stdout is decoded as one
33-// bounded binary response.
34-type PreviewClient struct {
35- command func(ctx context.Context, name string, args ...string) *exec.Cmd
36- timeout time.Duration
37-}
38-
39-var _ ports.RemotePreviewClient = (*PreviewClient)(nil)
40-
41-func NewPreviewClient() ports.RemotePreviewClient {
42- return &PreviewClient{command: exec.CommandContext}
43-}
44-
45-func (c *PreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error) {
46- request := protocol.RemotePreviewRequest{Version: protocol.RemotePreviewSchemaVersion, Target: target, Width: width, Height: height}
47- payload, err := sessionwire.EncodeClientMessage(request)
48- if err != nil {
49- return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreviewRequest
50- }
51- runCtx, cancel := context.WithTimeout(ctx, c.previewTimeout())
52- defer cancel()
53- command := c.command
54- if command == nil {
55- command = exec.CommandContext
56- }
57- encoded := base64.RawURLEncoding.EncodeToString(payload)
58- // Preview is background observation, not attach: it must never grab a TTY
59- // or mutate the user's known-hosts trust state, so it uses the same
60- // non-interactive argv builder as catalogue checks (batch mode, strict
61- // host-key checking, bounded connect, no TTY, refused host-key updates).
62- spec := sshstdio.BuildCommandForObservation(target.Endpoint, remotePreviewSSHConnectTimeout, "vev", "_remote-preview", encoded)
63- cmd := command(runCtx, spec.Path, spec.Args...)
64- stdout := boundedBuffer{limit: protocol.RemotePreviewMaxBytes}
65- stderr := boundedBuffer{limit: remotePreviewMaxDiagnostic}
66- cmd.Stdout = &stdout
67- cmd.Stderr = &stderr
68- cmd.WaitDelay = remotePreviewWaitDelay
69- if err := cmd.Run(); err != nil {
70- if ctxErr := runCtx.Err(); ctxErr != nil {
71- if parentErr := ctx.Err(); parentErr != nil {
72- return protocol.RemotePreview{}, parentErr
73- }
74- return protocol.RemotePreview{}, protocol.ErrRemotePreviewTimeout
75- }
76- if stdout.overflow || stderr.overflow {
77- return protocol.RemotePreview{}, errRemotePreviewTooLarge
78- }
79- if diagnostic := sanitizeCatalogDiagnostic(string(stderr.Bytes())); diagnostic != "" {
80- return protocol.RemotePreview{}, fmt.Errorf("%w: %v: %s", errRemotePreviewSSH, err, diagnostic)
81- }
82- return protocol.RemotePreview{}, fmt.Errorf("%w: %v", errRemotePreviewSSH, err)
83- }
84- if stdout.overflow || stderr.overflow {
85- return protocol.RemotePreview{}, errRemotePreviewTooLarge
86- }
87- previewMessage, err := sessionwire.DecodeServerEnvelope(stdout.Bytes())
88- if err != nil {
89- return protocol.RemotePreview{}, err
90- }
91- preview, ok := previewMessage.(protocol.RemotePreview)
92- if !ok {
93- return protocol.RemotePreview{}, protocol.ErrInvalidRemotePreview
94- }
95- if preview.Status != protocol.RemotePreviewOK {
96- return protocol.RemotePreview{}, fmt.Errorf("remote preview unavailable: status %d", preview.Status)
97- }
98- if preview.LifecycleID != target.LifecycleID || preview.TabID != target.LiveTabID {
99- return protocol.RemotePreview{}, errRemotePreviewIdentity
100- }
101- return preview, nil
102-}
103-
104-func (c *PreviewClient) previewTimeout() time.Duration {
105- if c != nil && c.timeout > 0 {
106- return c.timeout
107- }
108- return remotePreviewCommandTimeout
109-}
D internal/adapters/remote/preview_test.go +0 -181
@@ -1,181 +0,0 @@
1-package remote
2-
3-import (
4- "context"
5- "encoding/base64"
6- "errors"
7- "fmt"
8- "os/exec"
9- "strings"
10- "testing"
11- "time"
12-
13- "github.com/stretchr/testify/require"
14-
15- renderer "github.com/bnema/vev-vt"
16- "github.com/bnema/vev/internal/adapters/sessionwire"
17- "github.com/bnema/vev/internal/adapters/sshstdio"
18- "github.com/bnema/vev/internal/domain"
19- "github.com/bnema/vev/internal/protocol"
20-)
21-
22-func previewClientTargetForTest() domain.RemoteSessionTarget {
23- var lifecycle domain.SessionLifecycleID
24- lifecycle[0] = 1
25- return domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
26-}
27-
28-func TestRemotePreviewClientBuildsExactSSHCommandAndDecodesResponse(t *testing.T) {
29- var lifecycle domain.SessionLifecycleID
30- lifecycle[0] = 9
31- target := domain.RemoteSessionTarget{
32- Endpoint: "build@mule", DisplayOrigin: "mule", LifecycleID: lifecycle,
33- SessionName: "work", LiveTabID: "tab-1",
34- }
35- want := protocol.RemotePreview{
36- Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK,
37- LifecycleID: lifecycle, TabID: target.LiveTabID, Revision: 7, Width: 1, Height: 1,
38- Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}},
39- }
40- payload, err := sessionwire.EncodeServerMessage(want)
41- require.NoError(t, err)
42- require.NotNil(t, payload)
43-
44- var gotPath string
45- var gotArgs []string
46- wantCommand := sshstdio.BuildCommandForObservation(target.Endpoint, remotePreviewSSHConnectTimeout, "vev", "_remote-preview", "request")
47- client := &PreviewClient{command: func(ctx context.Context, name string, args ...string) *exec.Cmd {
48- gotPath = name
49- gotArgs = append([]string(nil), args...)
50- if len(args) != len(wantCommand.Args) {
51- t.Fatalf("ssh args = %#v, want observation argv %#v", args, wantCommand.Args)
52- }
53- remote := args[len(args)-1]
54- words := strings.Split(remote, " ")
55- if len(words) != 3 {
56- t.Fatalf("remote command = %q, want three shell words", remote)
57- }
58- encoded := strings.Trim(words[2], "'")
59- requestPayload, err := base64.RawURLEncoding.DecodeString(encoded)
60- if err != nil {
61- t.Fatalf("decode request: %v", err)
62- }
63- requestMessage, err := sessionwire.DecodeClientEnvelope(requestPayload)
64- if err != nil {
65- t.Fatalf("decode request payload: %v", err)
66- }
67- request, ok := requestMessage.(protocol.RemotePreviewRequest)
68- if !ok {
69- t.Fatalf("request payload = %T, want protocol.RemotePreviewRequest", requestMessage)
70- }
71- if request.Target != target || request.Width != 1 || request.Height != 1 {
72- t.Fatalf("request = %#v, want target and 1x1 dimensions", request)
73- }
74- return stdoutCmd(ctx, string(payload))
75- }}
76-
77- got, err := client.Preview(context.Background(), target, 1, 1)
78- require.NoError(t, err)
79- require.Equal(t, want, got)
80- require.Equal(t, wantCommand.Path, gotPath)
81- require.Equal(t, wantCommand.Args[:len(wantCommand.Args)-1], gotArgs[:len(gotArgs)-1],
82- "preview must use the observation-safe ssh argv")
83- require.Contains(t, gotArgs[len(gotArgs)-1], "'_remote-preview'")
84- require.Contains(t, gotArgs, "-T")
85- require.Contains(t, gotArgs, "UpdateHostKeys=no")
86-}
87-
88-func TestRemotePreviewClientRejectsMismatchedResponseIdentity(t *testing.T) {
89- target := previewClientTargetForTest()
90- var otherLifecycle domain.SessionLifecycleID
91- otherLifecycle[0] = 2
92- valid := protocol.RemotePreview{
93- Version: protocol.RemotePreviewSchemaVersion, Status: protocol.RemotePreviewOK,
94- LifecycleID: target.LifecycleID, TabID: target.LiveTabID, Revision: 1, Width: 1, Height: 1,
95- Cells: []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}},
96- }
97- tests := []struct {
98- name string
99- mutate func(*protocol.RemotePreview)
100- }{
101- {name: "lifecycle ID", mutate: func(preview *protocol.RemotePreview) { preview.LifecycleID = otherLifecycle }},
102- {name: "tab ID", mutate: func(preview *protocol.RemotePreview) { preview.TabID = "tab-2" }},
103- }
104- for _, tt := range tests {
105- t.Run(tt.name, func(t *testing.T) {
106- preview := valid
107- tt.mutate(&preview)
108- payload, err := sessionwire.EncodeServerMessage(preview)
109- require.NoError(t, err)
110- require.NotNil(t, payload)
111-
112- client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
113- return stdoutCmd(ctx, string(payload))
114- }}
115- _, err = client.Preview(context.Background(), target, 1, 1)
116- require.ErrorIs(t, err, errRemotePreviewIdentity)
117- })
118- }
119-}
120-
121-func TestRemotePreviewClientRejectsMalformedResponse(t *testing.T) {
122- client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
123- return stdoutCmd(ctx, "not-a-preview")
124- }}
125- var lifecycle domain.SessionLifecycleID
126- lifecycle[0] = 1
127- target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
128- _, err := client.Preview(context.Background(), target, 1, 1)
129- require.Error(t, err)
130-}
131-
132-func TestRemotePreviewClientRejectsOversizedOutput(t *testing.T) {
133- client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
134- return exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("head -c %d /dev/zero", protocol.RemotePreviewMaxBytes+1))
135- }}
136- target := previewClientTargetForTest()
137- _, err := client.Preview(context.Background(), target, 1, 1)
138- require.ErrorIs(t, err, errRemotePreviewTooLarge)
139-}
140-
141-func TestRemotePreviewClientReturnsStderrDiagnostic(t *testing.T) {
142- client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
143- return exec.CommandContext(ctx, "sh", "-c", "printf 'connection refused' >&2; exit 1")
144- }}
145- target := previewClientTargetForTest()
146- _, err := client.Preview(context.Background(), target, 1, 1)
147- require.ErrorIs(t, err, errRemotePreviewSSH)
148- require.Contains(t, err.Error(), "connection refused")
149-}
150-
151-func TestRemotePreviewClientReturnsInternalTimeoutSentinel(t *testing.T) {
152- client := &PreviewClient{
153- timeout: 20 * time.Millisecond,
154- command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
155- return exec.CommandContext(ctx, "sleep", "10")
156- },
157- }
158- _, err := client.Preview(context.Background(), previewClientTargetForTest(), 1, 1)
159- require.ErrorIs(t, err, protocol.ErrRemotePreviewTimeout)
160-}
161-
162-func TestRemotePreviewClientReturnsCallerCancellation(t *testing.T) {
163- started := make(chan struct{})
164- client := &PreviewClient{command: func(ctx context.Context, _ string, _ ...string) *exec.Cmd {
165- close(started)
166- return exec.CommandContext(ctx, "sleep", "10")
167- }}
168- target := previewClientTargetForTest()
169- ctx, cancel := context.WithCancel(context.Background())
170- defer cancel()
171- errCh := make(chan error, 1)
172- go func() {
173- _, err := client.Preview(ctx, target, 1, 1)
174- errCh <- err
175- }()
176- <-started
177- cancel()
178- err := <-errCh
179- require.ErrorIs(t, err, context.Canceled)
180- require.False(t, errors.Is(err, errRemotePreviewSSH))
181-}
D internal/adapters/remote/quic_dialer.go +0 -177
@@ -1,177 +0,0 @@
1-package remote
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "log/slog"
8- "net"
9- "os/exec"
10- "strconv"
11- "strings"
12- "time"
13-
14- "github.com/bnema/vev/internal/adapters/quic"
15- "github.com/bnema/vev/internal/adapters/sshstdio"
16- "github.com/bnema/vev/internal/ports"
17- "github.com/bnema/vev/internal/protocol/wire"
18-)
19-
20-// quicDialer is the default remote carriage: SSH launches one ephemeral
21-// QUIC bootstrap server, the readiness line binds the pinned dial, and a
22-// single authenticated stream carries the session. Command construction
23-// is structured (no shell interpolation); secret-bearing values never
24-// reach logs or error text.
25-type quicDialer struct {
26- target string
27- log *slog.Logger
28- observer ports.SerializedRuntimeObserver
29- launch *EndpointLaunch
30- // startBootstrap builds the SSH bootstrap child. It is a field
31- // (not a method) so tests can stub the child without a network.
32- startBootstrap func(ctx context.Context, target string, launch *EndpointLaunch, stderr *limitedBootstrapBuffer) bootstrapProcess
33-}
34-
35-var _ wire.Dialer = quicDialer{}
36-
37-func (d quicDialer) Dial(ctx context.Context) (wire.Transport, error) {
38- if err := ctx.Err(); err != nil {
39- return nil, err
40- }
41- bootstrapTimeout := 30 * time.Second
42- bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout)
43- defer bootstrapCancel()
44-
45- var stderr limitedBootstrapBuffer
46- start := d.startBootstrap
47- if start == nil {
48- start = startSSHBootstrap
49- }
50- proc := start(bootstrapCtx, d.target, d.launch, &stderr)
51- stdout, err := proc.StdoutPipe()
52- if err != nil {
53- return nil, quicUnavailable("bootstrap stdout", err)
54- }
55- if err := proc.Start(); err != nil {
56- _ = stdout.Close()
57- return nil, quicUnavailable("start bootstrap", err)
58- }
59- // Deterministic process cleanup: kill on every path except a
60- // transferred success. The bootstrap prints one readiness line
61- // from a detached proxy and exits, so Wait returns promptly and
62- // the session outlives this SSH child.
63- cleanup := true
64- waited := false
65- defer func() {
66- if cleanup {
67- _ = proc.Kill()
68- }
69- if !waited {
70- _ = proc.Wait()
71- }
72- _ = stdout.Close()
73- }()
74-
75- readiness, err := quic.ReadReadiness(bootstrapCtx, stdout)
76- if err != nil {
77- _ = proc.Kill()
78- _ = proc.Wait()
79- waited = true
80- cleanup = false
81- return nil, quicUnavailable("read bootstrap readiness", err)
82- }
83- // The bootstrap SSH channel served its purpose: the detached proxy
84- // owns the session lifetime, so reap the short-lived child before
85- // dialing. Do not kill it: SIGKILL would race the proxy detach.
86- if err := proc.Wait(); err != nil {
87- waited = true
88- cleanup = false
89- return nil, quicUnavailable("wait bootstrap", err)
90- }
91- waited = true
92- cleanup = false
93-
94- // Compose the dial address from the SSH target host plus the
95- // published port: the readiness record is host-independent and a
96- // remote loopback address is never dialed.
97- addr, err := resolveQUICPeer(d.target, readiness.Port)
98- if err != nil {
99- return nil, quicUnavailable("resolve QUIC peer", err)
100- }
101- transport, err := quic.Dial(bootstrapCtx, addr, readiness, quic.Config{}, 15*time.Second, quic.WithRuntimeObserver(d.observer))
102- if err != nil {
103- return nil, quicUnavailable("dial bootstrap QUIC", err)
104- }
105- return transport, nil
106-}
107-
108-// resolveQUICPeer composes the dial address from the SSH target host
109-// and the bootstrap-published port. user@ prefixes, explicit ports,
110-// and bracketed IPv6 literals are stripped to the bare host before
111-// the published port is attached.
112-func resolveQUICPeer(target string, port int) (string, error) {
113- if port <= 0 || port > 65535 {
114- return "", fmt.Errorf("invalid bootstrap port %d", port)
115- }
116- host := target
117- if at := strings.LastIndexByte(host, '@'); at >= 0 {
118- host = host[at+1:]
119- }
120- if h, _, err := net.SplitHostPort(host); err == nil {
121- host = h
122- }
123- host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
124- if host == "" {
125- return "", fmt.Errorf("empty bootstrap host")
126- }
127- return net.JoinHostPort(host, strconv.Itoa(port)), nil
128-}
129-
130-// startSSHBootstrap launches the real `_quic-bootstrap` SSH child.
131-func startSSHBootstrap(ctx context.Context, target string, launch *EndpointLaunch, stderr *limitedBootstrapBuffer) bootstrapProcess {
132- if launch != nil {
133- spec := sshstdio.BuildCommandForRemoteLaunchWithRoot(target, launch.Root, launch.OwnerToken, launch.Binary, launch.Environment, "_quic-bootstrap")
134- cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
135- cmd.Stderr = stderr
136- return execBootstrapProcess{cmd: cmd}
137- }
138- spec := sshstdio.BuildCommandForMode(target, "_quic-bootstrap", "")
139- cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
140- cmd.Stderr = stderr
141- return execBootstrapProcess{cmd: cmd}
142-}
143-
144-// bootstrapProcess is the stub seam for the SSH bootstrap child.
145-type bootstrapProcess interface {
146- StdoutPipe() (io.ReadCloser, error)
147- Start() error
148- Kill() error
149- Wait() error
150-}
151-
152-type execBootstrapProcess struct{ cmd *exec.Cmd }
153-
154-func (p execBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() }
155-
156-func (p execBootstrapProcess) Start() error { return p.cmd.Start() }
157-func (p execBootstrapProcess) Kill() error { return p.cmd.Process.Kill() }
158-func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }
159-
160-// limitedBootstrapBuffer bounds captured stderr; secret-bearing values
161-// are never logged (the bootstrap prints readiness on stdout only).
162-type limitedBootstrapBuffer struct {
163- buf []byte
164-}
165-
166-func (b *limitedBootstrapBuffer) Write(p []byte) (int, error) {
167- const maxBootstrapStderr = 4 << 10
168- if len(b.buf) < maxBootstrapStderr {
169- keep := min(len(p), maxBootstrapStderr-len(b.buf))
170- b.buf = append(b.buf, p[:keep]...)
171- }
172- return len(p), nil
173-}
174-
175-func quicUnavailable(action string, err error) error {
176- return fmt.Errorf("remote QUIC transport unavailable: %s: %w", action, err)
177-}
D internal/adapters/remote/quic_dialer_test.go +0 -183
@@ -1,183 +0,0 @@
1-package remote
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "io"
8- "sync"
9- "testing"
10- "time"
11-
12- "github.com/bnema/vev/internal/adapters/quic"
13- "github.com/bnema/vev/internal/protocol/wire"
14-)
15-
16-// stubBootstrapProcess replays one readiness line then exits, modelling
17-// the detached `_quic-bootstrap` child: the session outlives the child,
18-// so Wait succeeds promptly after the line is consumed.
19-type stubBootstrapProcess struct {
20- line []byte
21- waitErr error
22-
23- killOnce sync.Once
24- killed bool
25- waitOnce sync.Once
26- waited chan struct{}
27-}
28-
29-func newStubBootstrapProcess(line []byte, waitErr error) *stubBootstrapProcess {
30- return &stubBootstrapProcess{line: line, waitErr: waitErr, waited: make(chan struct{})}
31-}
32-
33-func (p *stubBootstrapProcess) StdoutPipe() (io.ReadCloser, error) {
34- return io.NopCloser(bytes.NewReader(append(append([]byte(nil), p.line...), '\n'))), nil
35-}
36-
37-func (p *stubBootstrapProcess) Start() error { return nil }
38-
39-func (p *stubBootstrapProcess) Kill() error {
40- p.killOnce.Do(func() { p.killed = true })
41- return nil
42-}
43-
44-func (p *stubBootstrapProcess) Wait() error {
45- p.waitOnce.Do(func() { close(p.waited) })
46- <-p.waited
47- return p.waitErr
48-}
49-
50-// stubQuicDialer runs quicDialer.Dial against the stub bootstrap child.
51-func stubQuicDialerForTest(target string, stub *stubBootstrapProcess) quicDialer {
52- return quicDialer{
53- target: target,
54- startBootstrap: func(context.Context, string, *EndpointLaunch, *limitedBootstrapBuffer) bootstrapProcess {
55- return stub
56- },
57- }
58-}
59-
60-func TestQUICDialerComposesHostPortAndAuthenticates(t *testing.T) {
61- server, readiness, err := quic.NewServer()
62- if err != nil {
63- t.Fatal(err)
64- }
65- defer func() { _ = server.Close() }()
66- raw, err := quic.EncodeReadiness(readiness)
67- if err != nil {
68- t.Fatal(err)
69- }
70-
71- accepted := make(chan wire.Transport, 1)
72- go func() {
73- transport, err := server.Accept(context.Background())
74- if err == nil {
75- accepted <- transport
76- }
77- }()
78-
79- // The SSH target carries a user prefix; the dial must resolve the
80- // bare host and attach the published port, never a remote loopback.
81- stub := newStubBootstrapProcess(raw, nil)
82- dialer := stubQuicDialerForTest("user@127.0.0.1", stub)
83- observer := &recordingObserver{}
84- dialer.observer = observer
85- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
86- defer cancel()
87- transport, err := dialer.Dial(ctx)
88- if err != nil {
89- t.Fatalf("Dial() error = %v", err)
90- }
91- defer func() { _ = transport.Close() }()
92-
93- select {
94- case peer := <-accepted:
95- defer func() { _ = peer.Close() }()
96- payload := []byte("dialer session stays exact")
97- if err := transport.Send(wire.Envelope{Payload: payload}); err != nil {
98- t.Fatalf("Send() error = %v", err)
99- }
100- got, err := peer.Recv()
101- if err != nil {
102- t.Fatalf("Recv() error = %v", err)
103- }
104- if !bytes.Equal(got.Payload, payload) {
105- t.Fatalf("payload = %q, want %q", got.Payload, payload)
106- }
107- case <-ctx.Done():
108- t.Fatal("bootstrap accept timed out")
109- }
110- // The short-lived bootstrap child was reaped, never killed: the
111- // session outlives the SSH channel by design.
112- if stub.killed {
113- t.Fatal("bootstrap child was killed; the detached proxy must outlive it")
114- }
115- if observer.count() == 0 {
116- t.Fatal("runtime observer was not propagated to the QUIC transport")
117- }
118- select {
119- case <-stub.waited:
120- default:
121- t.Fatal("bootstrap child was not reaped")
122- }
123-}
124-
125-func TestQUICDialerRejectsFailedBootstrapWait(t *testing.T) {
126- server, readiness, err := quic.NewServer()
127- if err != nil {
128- t.Fatal(err)
129- }
130- defer func() { _ = server.Close() }()
131- raw, err := quic.EncodeReadiness(readiness)
132- if err != nil {
133- t.Fatal(err)
134- }
135- dialer := stubQuicDialerForTest("127.0.0.1", newStubBootstrapProcess(raw, errors.New("exit status 1")))
136- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
137- defer cancel()
138- transport, err := dialer.Dial(ctx)
139- if err == nil {
140- _ = transport.Close()
141- t.Fatal("Dial() error = nil, want bootstrap wait failure")
142- }
143- if transport != nil {
144- t.Fatalf("Dial() transport = %T, want nil", transport)
145- }
146-}
147-
148-func TestResolveQUICPeer(t *testing.T) {
149- tests := []struct {
150- name string
151- target string
152- port int
153- want string
154- wantErr bool
155- }{
156- {name: "bare host", target: "remote.example", port: 4433, want: "remote.example:4433"},
157- {name: "user prefix", target: "user@remote.example", port: 4433, want: "remote.example:4433"},
158- {name: "loopback", target: "127.0.0.1", port: 4433, want: "127.0.0.1:4433"},
159- {name: "explicit port replaced", target: "remote.example:22", port: 4433, want: "remote.example:4433"},
160- {name: "bracketed ipv6", target: "[::1]", port: 4433, want: "[::1]:4433"},
161- {name: "ipv6 with port", target: "[::1]:22", port: 4433, want: "[::1]:4433"},
162- {name: "zero port", target: "remote.example", port: 0, wantErr: true},
163- {name: "port overflow", target: "remote.example", port: 65536, wantErr: true},
164- {name: "empty host", target: "user@", port: 4433, wantErr: true},
165- }
166- for _, tt := range tests {
167- t.Run(tt.name, func(t *testing.T) {
168- got, err := resolveQUICPeer(tt.target, tt.port)
169- if tt.wantErr {
170- if err == nil {
171- t.Fatalf("resolveQUICPeer() error = nil, want failure")
172- }
173- return
174- }
175- if err != nil {
176- t.Fatalf("resolveQUICPeer() error = %v", err)
177- }
178- if got != tt.want {
179- t.Fatalf("resolveQUICPeer() = %q, want %q", got, tt.want)
180- }
181- })
182- }
183-}
D internal/adapters/remote/remote_factory.go +0 -93
@@ -1,93 +0,0 @@
1-package remote
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "log/slog"
8-
9- "github.com/bnema/vev/internal/adapters/sshstdio"
10- "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/protocol/wire"
12-)
13-
14-// TransportMode selects a concrete remote carriage after app validation.
15-type TransportMode string
16-
17-const (
18- // TransportQUIC is the default remote carriage: one authenticated
19- // QUIC stream per connection (P6.3). TransportStdio selects the
20- // explicit SSH-only carriage.
21- TransportQUIC TransportMode = "quic"
22- TransportStdio TransportMode = "stdio"
23-)
24-
25-// DialerFactory constructs target-specific raw carriage dialers.
26-type DialerFactory struct {
27- observer ports.SerializedRuntimeObserver
28-}
29-
30-// EndpointLaunch contains the absolute remote executable and complete child
31-// environment selected by an explicit UI-driver launch configuration.
32-type EndpointLaunch struct {
33- Binary string
34- Root string
35- OwnerToken string
36- Environment []string
37-}
38-
39-func NewDialerFactory() DialerFactory { return DialerFactory{} }
40-
41-func NewDialerFactoryWithRuntimeObserver(observer ports.SerializedRuntimeObserver) DialerFactory {
42- return DialerFactory{observer: observer}
43-}
44-
45-func (f DialerFactory) DialerForRemote(target, session string, mode TransportMode, log *slog.Logger) (wire.Dialer, error) {
46- return f.dialerForRemote(target, session, mode, log, nil)
47-}
48-
49-// DialerForRemoteWithLaunch selects the same normal carriage while using the
50-// explicit endpoint executable/environment for remote child startup.
51-func (f DialerFactory) DialerForRemoteWithLaunch(target, session string, mode TransportMode, log *slog.Logger, launch *EndpointLaunch) (wire.Dialer, error) {
52- if launch != nil && (launch.Binary == "" || launch.Root == "" || launch.OwnerToken == "") {
53- return nil, errors.New("vev: isolated remote launch requires a binary, root, and owner")
54- }
55- return f.dialerForRemote(target, session, mode, log, launch)
56-}
57-
58-func (f DialerFactory) dialerForRemote(target, session string, mode TransportMode, log *slog.Logger, launch *EndpointLaunch) (wire.Dialer, error) {
59- switch mode {
60- case TransportQUIC:
61- if log != nil {
62- log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
63- }
64- return quicDialer{target: target, log: log, observer: f.observer, launch: launch}, nil
65- case TransportStdio:
66- if log != nil {
67- log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
68- }
69- var selected *EndpointLaunch
70- if launch != nil {
71- copyLaunch := *launch
72- copyLaunch.Environment = append([]string(nil), launch.Environment...)
73- selected = &copyLaunch
74- }
75- return stdioDialer{target: target, log: log, observer: f.observer, launch: selected}, nil
76- default:
77- return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
78- }
79-}
80-
81-type stdioDialer struct {
82- target string
83- log *slog.Logger
84- observer ports.SerializedRuntimeObserver
85- launch *EndpointLaunch
86-}
87-
88-func (d stdioDialer) Dial(ctx context.Context) (wire.Transport, error) {
89- if d.launch == nil {
90- return sshstdio.DialContextWithRuntimeObserver(ctx, d.target, "", d.log, sshstdio.WithRuntimeObserver(d.observer))
91- }
92- return sshstdio.DialContextWithIsolatedLaunch(ctx, d.target, d.launch.Root, d.launch.OwnerToken, d.launch.Binary, d.launch.Environment, d.log, sshstdio.WithRuntimeObserver(d.observer))
93-}
D internal/adapters/remote/remote_factory_test.go +0 -129
@@ -1,129 +0,0 @@
1-package remote
2-
3-import (
4- "context"
5- "errors"
6- "sync"
7- "testing"
8-
9- "github.com/bnema/vev/internal/ports"
10-)
11-
12-type recordingObserver struct {
13- mu sync.Mutex
14- marks []ports.RuntimeMark
15-}
16-
17-func (r *recordingObserver) ObserveRuntime(mark ports.RuntimeMark) {
18- r.mu.Lock()
19- r.marks = append(r.marks, mark)
20- r.mu.Unlock()
21-}
22-
23-func (r *recordingObserver) Flush() {}
24-func (r *recordingObserver) Close() {}
25-
26-func (r *recordingObserver) count() int {
27- r.mu.Lock()
28- defer r.mu.Unlock()
29- return len(r.marks)
30-}
31-
32-func TestDialerFactorySelectsExplicitModes(t *testing.T) {
33- factory := NewDialerFactory()
34-
35- tests := []struct {
36- name string
37- mode TransportMode
38- wantType any
39- }{
40- {name: "quic", mode: TransportQUIC, wantType: quicDialer{}},
41- {name: "stdio", mode: TransportStdio, wantType: stdioDialer{}},
42- }
43- for _, tt := range tests {
44- t.Run(tt.name, func(t *testing.T) {
45- dialer, err := factory.DialerForRemote("remote.example", "work", tt.mode, nil)
46- if err != nil {
47- t.Fatalf("DialerForRemote() error = %v", err)
48- }
49- switch tt.wantType.(type) {
50- case quicDialer:
51- got, ok := dialer.(quicDialer)
52- if !ok {
53- t.Fatalf("dialer type = %T, want %T", dialer, quicDialer{})
54- }
55- if got.target != "remote.example" {
56- t.Fatalf("dialer target = %q, want %q", got.target, "remote.example")
57- }
58- case stdioDialer:
59- got, ok := dialer.(stdioDialer)
60- if !ok {
61- t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{})
62- }
63- if got.target != "remote.example" {
64- t.Fatalf("dialer target = %q, want %q", got.target, "remote.example")
65- }
66- }
67- })
68- }
69-}
70-
71-func TestDialerFactoryPropagatesObserverToQUICTransport(t *testing.T) {
72- observer := &recordingObserver{}
73- factory := NewDialerFactoryWithRuntimeObserver(observer)
74- dialer, err := factory.DialerForRemote("remote.example", "work", TransportQUIC, nil)
75- if err != nil {
76- t.Fatalf("DialerForRemote() error = %v", err)
77- }
78- got, ok := dialer.(quicDialer)
79- if !ok {
80- t.Fatalf("dialer type = %T, want %T", dialer, quicDialer{})
81- }
82- if got.observer != observer {
83- t.Fatalf("quic dialer observer = %v, want the factory observer", got.observer)
84- }
85-}
86-
87-func TestDialerFactoryRejectsIncompleteIsolatedLaunch(t *testing.T) {
88- factory := NewDialerFactory()
89- for _, launch := range []*EndpointLaunch{
90- {Binary: "/bin/vev"},
91- {Binary: "/bin/vev", Root: "/tmp/root"},
92- {Root: "/tmp/root"},
93- {Root: "/tmp/root", OwnerToken: "token"},
94- } {
95- _, err := factory.DialerForRemoteWithLaunch("remote.example", "work", TransportStdio, nil, launch)
96- if err == nil {
97- t.Fatalf("DialerForRemoteWithLaunch(%#v) error = nil, want incomplete launch rejection", launch)
98- }
99- }
100-}
101-
102-func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
103- factory := NewDialerFactory()
104-
105- dialer, err := factory.DialerForRemote("remote.example", "work", TransportMode("serial"), nil)
106- if err == nil {
107- t.Fatal("DialerForRemote() error = nil, want unsupported mode error")
108- }
109- if dialer != nil {
110- t.Fatalf("DialerForRemote() dialer = %T, want nil", dialer)
111- }
112- if got, want := err.Error(), "vev: unsupported remote transport \"serial\""; got != want {
113- t.Fatalf("error = %q, want %q", got, want)
114- }
115-}
116-
117-func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) {
118- dialer := stdioDialer{target: "remote.example"}
119- ctx, cancel := context.WithCancel(context.Background())
120- cancel()
121-
122- transport, err := dialer.Dial(ctx)
123- if !errors.Is(err, context.Canceled) {
124- t.Fatalf("Dial() error = %v, want context canceled", err)
125- }
126- if transport != nil {
127- t.Fatalf("Dial() transport = %T, want nil", transport)
128- }
129-}
D internal/adapters/remote/runtime.go +0 -215
@@ -1,215 +0,0 @@
1-package remote
2-
3-import (
4- "context"
5- "errors"
6- "log/slog"
7- "sync"
8- "time"
9-
10- "github.com/bnema/vev/internal/domain"
11- appports "github.com/bnema/vev/internal/ports"
12-)
13-
14-const (
15- // observeWorkerCount bounds concurrent catalogue observations so four
16- // hung checks can never starve the registry, cache, or store lanes.
17- observeWorkerCount = 4
18- // defaultObserveTimeout bounds one catalogue observation when the
19- // admitted job carries no deadline.
20- defaultObserveTimeout = 10 * time.Second
21-)
22-
23-// errRemoteLaneBusy retires a job the service admitted without credit. The
24-// service never issues more jobs of a kind than its credits; this is
25-// defense-in-depth so a wedged admission cannot wedge the runtime.
26-var errRemoteLaneBusy = errors.New("remote runtime: bounded lane occupied")
27-
28-// Runtime is the bounded infrastructure seam executing remote jobs. It owns
29-// subprocesses, storage calls and per-lane dispatch; its workers never call
30-// into the use case. Lanes are independent: one registry reader, one startup
31-// cache reader, one cache writer, four catalogue observers.
32-type Runtime struct {
33- store appports.RemoteHostStore
34- catalog appports.RemoteCatalogClient
35- cache appports.RemoteCatalogCache
36- log *slog.Logger
37-}
38-
39-// NewRuntime composes a Runtime from the existing registry, catalogue and
40-// cache adapters. All arguments must be non-nil.
41-func NewRuntime(store appports.RemoteHostStore, catalog appports.RemoteCatalogClient, cache appports.RemoteCatalogCache, log *slog.Logger) *Runtime {
42- if log == nil {
43- log = slog.Default()
44- }
45- return &Runtime{store: store, catalog: catalog, cache: cache, log: log}
46-}
47-
48-var _ appports.RemoteRuntime = (*Runtime)(nil)
49-
50-// Run executes admitted jobs until ctx is cancelled or the job channel
51-// closes. Dispatch never blocks forwarding into an occupied lane; cancelled
52-// completions select on context cancellation. It returns nil on cooperative
53-// shutdown; lane failures travel through results, never through Run.
54-// Cancellation is owned explicitly: workers run under a derived context so
55-// closing the job channel retires in-flight delivery and reaps every worker
56-// instead of hanging behind idle lanes with a live parent context.
57-func (r *Runtime) Run(ctx context.Context, jobs <-chan appports.RemoteJob, results chan<- appports.RemoteJobResult) error {
58- runCtx, stop := context.WithCancel(ctx)
59- defer stop()
60- registryLane := make(chan appports.RemoteJob, 1)
61- cacheLoadLane := make(chan appports.RemoteJob, 1)
62- storeLane := make(chan appports.RemoteJob, 1)
63- // The observe buffer matches the worker count: under correct service
64- // credits forwarding never blocks, and scheduling jitter between rapid
65- // admissions cannot retire a properly admitted job as lane-busy.
66- observeLane := make(chan appports.RemoteJob, observeWorkerCount)
67-
68- var workers sync.WaitGroup
69- ready := make(chan struct{}, observeWorkerCount+3)
70- park := func(lane <-chan appports.RemoteJob, work func(appports.RemoteJob)) {
71- defer workers.Done()
72- ready <- struct{}{}
73- for {
74- select {
75- case <-runCtx.Done():
76- return
77- case job, ok := <-lane:
78- if !ok {
79- return
80- }
81- work(job)
82- }
83- }
84- }
85- workers.Add(1)
86- go park(registryLane, func(job appports.RemoteJob) {
87- pinned, learned, err := r.store.Hosts()
88- r.complete(runCtx, results, appports.RemoteJobResult{
89- Kind: job.Kind, Endpoint: job.Endpoint,
90- Registration: job.Registration, Generation: job.Generation,
91- Registrations: combineRegistrations(pinned, learned), Err: err,
92- })
93- })
94- workers.Add(1)
95- go park(cacheLoadLane, func(job appports.RemoteJob) {
96- entries, err := r.cache.Load()
97- r.complete(runCtx, results, appports.RemoteJobResult{
98- Kind: job.Kind, Endpoint: job.Endpoint,
99- Registration: job.Registration, Generation: job.Generation,
100- Entries: entries, Err: err,
101- })
102- })
103- workers.Add(1)
104- go park(storeLane, func(job appports.RemoteJob) {
105- err := r.cache.Store(job.Entries)
106- r.complete(runCtx, results, appports.RemoteJobResult{
107- Kind: job.Kind, Endpoint: job.Endpoint,
108- Registration: job.Registration, Generation: job.Generation,
109- Err: err,
110- })
111- })
112- for i := 0; i < observeWorkerCount; i++ {
113- workers.Add(1)
114- go park(observeLane, func(job appports.RemoteJob) {
115- timeout := job.Deadline
116- if timeout <= 0 {
117- timeout = defaultObserveTimeout
118- }
119- observeCtx, cancel := context.WithTimeout(runCtx, timeout)
120- catalog, err := r.catalog.List(observeCtx, job.Endpoint)
121- cancel()
122- r.complete(runCtx, results, appports.RemoteJobResult{
123- Kind: job.Kind, Endpoint: job.Endpoint,
124- Registration: job.Registration, Generation: job.Generation,
125- FailureKind: ClassifyCatalogError(err), Catalog: catalog, Err: err,
126- })
127- })
128- }
129- // All lanes parked before the first admission: an immediate send can
130- // never retire as lane-busy for lack of a ready receiver.
131- for i := 0; i < observeWorkerCount+3; i++ {
132- select {
133- case <-ready:
134- case <-ctx.Done():
135- workers.Wait()
136- return nil
137- }
138- }
139-
140- for {
141- select {
142- case <-ctx.Done():
143- workers.Wait()
144- return nil
145- case job, ok := <-jobs:
146- if !ok {
147- stop()
148- workers.Wait()
149- return nil
150- }
151- var lane chan appports.RemoteJob
152- switch job.Kind {
153- case appports.RemoteJobRegistryRead:
154- lane = registryLane
155- case appports.RemoteJobCacheLoad:
156- lane = cacheLoadLane
157- case appports.RemoteJobCacheStore:
158- lane = storeLane
159- case appports.RemoteJobCatalogObserve:
160- lane = observeLane
161- default:
162- r.complete(runCtx, results, appports.RemoteJobResult{
163- Kind: job.Kind, Endpoint: job.Endpoint,
164- Registration: job.Registration, Generation: job.Generation,
165- Err: errors.New("remote runtime: unknown job kind"),
166- })
167- continue
168- }
169- select {
170- case lane <- job:
171- case <-ctx.Done():
172- workers.Wait()
173- return nil
174- default:
175- r.log.Debug("remote runtime lane occupied, retiring job", "kind", job.Kind.String(), "endpoint", job.Endpoint)
176- r.complete(runCtx, results, appports.RemoteJobResult{
177- Kind: job.Kind, Endpoint: job.Endpoint,
178- Registration: job.Registration, Generation: job.Generation,
179- Err: errRemoteLaneBusy,
180- })
181- }
182- }
183- }
184-}
185-
186-// complete delivers exactly one completion, abandoning delivery when the
187-// service is already gone so shutdown never wedges on an unread result.
188-func (r *Runtime) complete(ctx context.Context, results chan<- appports.RemoteJobResult, result appports.RemoteJobResult) {
189- select {
190- case results <- result:
191- case <-ctx.Done():
192- }
193-}
194-
195-// combineRegistrations orders pinned records first in stored order, then
196-// learned-only records, so the monitor can assign stable presentation ranks.
197-func combineRegistrations(pinned, learned []domain.RemoteRegistration) []domain.RemoteRegistration {
198- out := make([]domain.RemoteRegistration, 0, len(pinned)+len(learned))
199- seen := make(map[string]struct{}, len(pinned)+len(learned))
200- for _, record := range pinned {
201- if _, dup := seen[record.Endpoint]; dup {
202- continue
203- }
204- seen[record.Endpoint] = struct{}{}
205- out = append(out, record)
206- }
207- for _, record := range learned {
208- if _, dup := seen[record.Endpoint]; dup {
209- continue
210- }
211- seen[record.Endpoint] = struct{}{}
212- out = append(out, record)
213- }
214- return out
215-}
D internal/adapters/remote/runtime_test.go +0 -350
@@ -1,350 +0,0 @@
1-package remote
2-
3-import (
4- "context"
5- "errors"
6- "sync"
7- "testing"
8- "time"
9-
10- "github.com/bnema/vev/internal/domain"
11- appports "github.com/bnema/vev/internal/ports"
12- "github.com/bnema/vev/internal/protocol/catalogue"
13-)
14-
15-type blockingCache struct {
16- release chan struct{}
17- done chan struct{}
18- mu sync.Mutex
19- stores [][]catalogue.RemoteCatalogCacheEntry
20-}
21-
22-func newBlockingCache(t *testing.T) *blockingCache {
23- t.Helper()
24- cache := &blockingCache{release: make(chan struct{}), done: make(chan struct{})}
25- t.Cleanup(func() {
26- select {
27- case <-cache.done:
28- default:
29- close(cache.done)
30- }
31- })
32- return cache
33-}
34-
35-func (c *blockingCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { return nil, nil }
36-
37-func (c *blockingCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
38- c.mu.Lock()
39- c.stores = append(c.stores, entries)
40- c.mu.Unlock()
41- select {
42- case <-c.release:
43- return nil
44- case <-c.done:
45- return errors.New("test teardown releases blocked store")
46- }
47-}
48-
49-type instantCatalog struct {
50- mu sync.Mutex
51- calls []string
52- deadline []time.Duration
53-}
54-
55-func (c *instantCatalog) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) {
56- deadline, _ := ctx.Deadline()
57- c.mu.Lock()
58- c.calls = append(c.calls, target)
59- c.deadline = append(c.deadline, time.Until(deadline))
60- c.mu.Unlock()
61- return catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}}}, nil
62-}
63-
64-type hangingCatalog struct{ started chan struct{} }
65-
66-func (c *hangingCatalog) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) {
67- close(c.started)
68- <-ctx.Done()
69- return catalogue.RemoteCatalog{}, ctx.Err()
70-}
71-
72-type recordStore struct {
73- pinned []domain.RemoteRegistration
74- learned []domain.RemoteRegistration
75- err error
76-}
77-
78-func testRecord(endpoint string, incarnation byte) domain.RemoteRegistration {
79- return domain.RemoteRegistration{Endpoint: endpoint, Incarnation: [16]byte{incarnation}, Generation: 1}
80-}
81-
82-func (s *recordStore) Hosts() ([]domain.RemoteRegistration, []domain.RemoteRegistration, error) {
83- return s.pinned, s.learned, s.err
84-}
85-func (*recordStore) AddPinned(string) error { return nil }
86-func (*recordStore) RemovePinned(string) error { return nil }
87-func (*recordStore) Remember(string) error { return nil }
88-func (*recordStore) Forget(string) error { return nil }
89-func (*recordStore) Remove(string) (bool, error) {
90- return false, nil
91-}
92-
93-func startRuntime(t *testing.T, runtime *Runtime) (chan<- appports.RemoteJob, <-chan appports.RemoteJobResult, context.CancelFunc) {
94- t.Helper()
95- ctx, cancel := context.WithCancel(context.Background())
96- jobs := make(chan appports.RemoteJob, 16)
97- results := make(chan appports.RemoteJobResult, 16)
98- done := make(chan error, 1)
99- go func() { done <- runtime.Run(ctx, jobs, results) }()
100- t.Cleanup(func() {
101- cancel()
102- select {
103- case <-done:
104- case <-time.After(5 * time.Second):
105- t.Fatalf("runtime did not stop")
106- }
107- })
108- return jobs, results, cancel
109-}
110-
111-func receiveResult(t *testing.T, ch <-chan appports.RemoteJobResult, what string) appports.RemoteJobResult {
112- t.Helper()
113- select {
114- case result := <-ch:
115- return result
116- case <-time.After(5 * time.Second):
117- t.Fatalf("timed out waiting for %s", what)
118- return appports.RemoteJobResult{}
119- }
120-}
121-
122-func TestRuntimeBlockedWriterDoesNotStallOtherLanes(t *testing.T) {
123- t.Parallel()
124-
125- cache := &blockingCache{release: make(chan struct{})}
126- catalog := &instantCatalog{}
127- store := &recordStore{
128- pinned: []domain.RemoteRegistration{testRecord("user@arch", 1)},
129- learned: []domain.RemoteRegistration{testRecord("user@mule", 2)},
130- }
131- runtime := NewRuntime(store, catalog, cache, nil)
132- jobs, results, _ := startRuntime(t, runtime)
133- // Release the blocked writer on any early failure so the runtime
134- // cleanup's bounded join never wedges behind this test's Fatalf.
135- t.Cleanup(func() {
136- select {
137- case <-cache.release:
138- default:
139- close(cache.release)
140- }
141- })
142-
143- // Occupy the single cache writer, then prove registry reads and all
144- // four observation lanes make progress around the blocked write.
145- jobs <- appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "user@arch"}}}
146- jobs <- appports.RemoteJob{Kind: appports.RemoteJobRegistryRead}
147- for _, endpoint := range []string{"a", "b", "c", "d"} {
148- jobs <- appports.RemoteJob{Kind: appports.RemoteJobCatalogObserve, Endpoint: endpoint}
149- }
150-
151- registry := appports.RemoteJobResult{}
152- observes := 0
153- // Lanes run independently: the registry read and the four
154- // observations complete in arrival order, not admission order.
155- for i := 0; i < 5; i++ {
156- result := receiveResult(t, results, "registry or observe")
157- switch result.Kind {
158- case appports.RemoteJobRegistryRead:
159- if registry.Kind != 0 {
160- t.Fatalf("duplicate registry result = %+v", result)
161- }
162- registry = result
163- case appports.RemoteJobCatalogObserve:
164- if result.Err != nil || result.FailureKind != domain.RemoteFailureNone {
165- t.Fatalf("observe result = %+v", result)
166- }
167- if len(result.Catalog.Sessions) != 1 {
168- t.Fatalf("observe catalog = %+v", result.Catalog)
169- }
170- observes++
171- default:
172- t.Fatalf("unexpected result kind = %+v", result)
173- }
174- }
175- if registry.Err != nil {
176- t.Fatalf("registry result = %+v", registry)
177- }
178- if len(registry.Registrations) != 2 || registry.Registrations[0].Endpoint != "user@arch" || registry.Registrations[1].Endpoint != "user@mule" {
179- t.Fatalf("registrations = %+v, want pinned-then-learned order", registry.Registrations)
180- }
181- if observes != 4 {
182- t.Fatalf("observes = %d, want 4", observes)
183- }
184-
185- // Two further stores arrive while the writer is occupied. The lane admits
186- // exactly one of them and retires the other as lane-busy rather than
187- // queueing without bound; which of the two wins is admission timing, so
188- // the assertions follow the reported outcome instead of naming a winner.
189- second := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Endpoint: "second", Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "second"}}}
190- third := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Endpoint: "third", Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "third"}}}
191- jobs <- second
192- jobs <- third
193- busy := receiveResult(t, results, "lane-busy store")
194- if busy.Kind != appports.RemoteJobCacheStore || !errors.Is(busy.Err, errRemoteLaneBusy) {
195- t.Fatalf("bounded-lane store result = %+v, want lane-busy failure", busy)
196- }
197- if busy.Endpoint != second.Endpoint && busy.Endpoint != third.Endpoint {
198- t.Fatalf("lane-busy store endpoint = %q, want one of the two admitted stores", busy.Endpoint)
199- }
200- admitted := second.Endpoint
201- if busy.Endpoint == second.Endpoint {
202- admitted = third.Endpoint
203- }
204-
205- close(cache.release)
206- firstStored := receiveResult(t, results, "cache store")
207- if firstStored.Kind != appports.RemoteJobCacheStore || firstStored.Err != nil {
208- t.Fatalf("store result = %+v", firstStored)
209- }
210- admittedStored := receiveResult(t, results, "admitted cache store")
211- if admittedStored.Kind != appports.RemoteJobCacheStore || admittedStored.Err != nil || admittedStored.Endpoint != admitted {
212- t.Fatalf("admitted store result = %+v, want the store the lane accepted (%s)", admittedStored, admitted)
213- }
214- cache.mu.Lock()
215- defer cache.mu.Unlock()
216- if len(cache.stores) != 2 || len(cache.stores[0]) != 1 || cache.stores[0][0].Host != "user@arch" ||
217- len(cache.stores[1]) != 1 || cache.stores[1][0].Host != admitted {
218- t.Fatalf("stored payloads = %+v, want the blocked store first then %s", cache.stores, admitted)
219- }
220-}
221-
222-func TestRuntimeObserveHonorsDeadlineAndCancel(t *testing.T) {
223- t.Parallel()
224-
225- catalog := &instantCatalog{}
226- runtime := NewRuntime(&recordStore{}, catalog, &blockingCache{release: make(chan struct{})}, nil)
227- jobs, results, _ := startRuntime(t, runtime)
228-
229- jobs <- appports.RemoteJob{Kind: appports.RemoteJobCatalogObserve, Endpoint: "user@arch", Deadline: 5 * time.Second}
230- result := receiveResult(t, results, "bounded observe")
231- if result.Err != nil {
232- t.Fatalf("observe result = %+v", result)
233- }
234- catalog.mu.Lock()
235- defer catalog.mu.Unlock()
236- if len(catalog.calls) != 1 || catalog.calls[0] != "user@arch" {
237- t.Fatalf("catalog calls = %v", catalog.calls)
238- }
239- if catalog.deadline[0] <= 0 || catalog.deadline[0] > 5*time.Second {
240- t.Fatalf("observe ctx deadline in %v, want within (0, 5s]", catalog.deadline[0])
241- }
242-}
243-
244-func TestRuntimeObserveCancelReapsWorker(t *testing.T) {
245- t.Parallel()
246-
247- catalog := &hangingCatalog{started: make(chan struct{})}
248- runtime := NewRuntime(&recordStore{}, catalog, &blockingCache{release: make(chan struct{})}, nil)
249- ctx, cancel := context.WithCancel(context.Background())
250- jobs := make(chan appports.RemoteJob, 4)
251- results := make(chan appports.RemoteJobResult, 4)
252- done := make(chan error, 1)
253- go func() { done <- runtime.Run(ctx, jobs, results) }()
254-
255- jobs <- appports.RemoteJob{Kind: appports.RemoteJobCatalogObserve, Endpoint: "user@arch", Deadline: time.Minute}
256- select {
257- case <-catalog.started:
258- case <-time.After(5 * time.Second):
259- t.Fatal("observation did not start")
260- }
261- cancel()
262- // Delivery is abandoned once the runtime context is cancelled, so a
263- // cancelled observation either reports context.Canceled or never
264- // reports at all; either way the runtime itself must stop. Note done
265- // is buffered depth one: once observed stopped, do not wait on it
266- // again.
267- stopped := false
268- select {
269- case result := <-results:
270- if !errors.Is(result.Err, context.Canceled) {
271- t.Fatalf("cancelled observe err = %v, want context.Canceled", result.Err)
272- }
273- case <-done:
274- stopped = true
275- case <-time.After(5 * time.Second):
276- t.Fatal("cancelled observation neither completed nor stopped the runtime")
277- }
278- if !stopped {
279- select {
280- case <-done:
281- case <-time.After(5 * time.Second):
282- t.Fatal("runtime did not stop after cancel")
283- }
284- }
285-}
286-
287-func TestRuntimeJobsCloseReapsWorkers(t *testing.T) {
288- t.Parallel()
289-
290- // Closing the job channel with a live parent context must still
291- // reap every worker: the runtime owns cancellation explicitly
292- // instead of hanging behind idle lanes.
293- runtime := NewRuntime(&recordStore{}, &instantCatalog{}, &blockingCache{release: make(chan struct{})}, nil)
294- jobs := make(chan appports.RemoteJob, 4)
295- results := make(chan appports.RemoteJobResult, 4)
296- done := make(chan error, 1)
297- go func() { done <- runtime.Run(context.Background(), jobs, results) }()
298-
299- close(jobs)
300- select {
301- case err := <-done:
302- if err != nil {
303- t.Fatalf("Run() error = %v, want nil", err)
304- }
305- case <-time.After(5 * time.Second):
306- t.Fatal("runtime did not stop after jobs closed with live context")
307- }
308-}
309-
310-func TestRuntimeCancelledCompletionDoesNotWedge(t *testing.T) {
311- t.Parallel()
312-
313- runtime := NewRuntime(&recordStore{}, &instantCatalog{}, &blockingCache{release: make(chan struct{})}, nil)
314- ctx, cancel := context.WithCancel(context.Background())
315- jobs := make(chan appports.RemoteJob, 4)
316- // Unbuffered, unconsumed results: completion must abandon delivery on
317- // cancellation instead of wedging shutdown.
318- results := make(chan appports.RemoteJobResult)
319- done := make(chan error, 1)
320- go func() { done <- runtime.Run(ctx, jobs, results) }()
321-
322- jobs <- appports.RemoteJob{Kind: appports.RemoteJobRegistryRead}
323- // Let the worker finish and park in completion delivery with no
324- // consumer on the unbuffered results channel.
325- time.Sleep(200 * time.Millisecond)
326- cancel()
327- select {
328- case <-done:
329- case <-time.After(5 * time.Second):
330- t.Fatal("runtime wedged on unread result")
331- }
332-}
333-
334-func TestCombineRegistrations(t *testing.T) {
335- t.Parallel()
336-
337- archPinned := testRecord("user@arch", 1)
338- archLearned := testRecord("user@arch", 9)
339- mule := testRecord("user@mule", 2)
340- got := combineRegistrations(
341- []domain.RemoteRegistration{archPinned},
342- []domain.RemoteRegistration{mule, archLearned},
343- )
344- if len(got) != 2 || got[0] != archPinned || got[1] != mule {
345- t.Fatalf("combine = %+v, want pinned-then-learned-only", got)
346- }
347- if len(combineRegistrations(nil, nil)) != 0 {
348- t.Fatal("empty combine must stay empty")
349- }
350-}
M internal/adapters/sessionwire/proto_convert_session.go +46 -8
@@ -8,6 +8,8 @@ import (
88 "math"
99 "sync"
1010
11+ "google.golang.org/protobuf/proto"
12+
1113 "github.com/bnema/vev/internal/domain"
1214 "github.com/bnema/vev/internal/protocol"
1315 "github.com/bnema/vev/internal/protocol/wire"
@@ -22,7 +24,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
2224 if err != nil {
2325 return nil, err
2426 }
25- remote, err := remoteTargetToWire(message.RemoteTarget)
27+ sessionTarget, err := sessionAttachTargetToWire(message.SessionTarget)
2628 if err != nil {
2729 return nil, err
2830 }
@@ -42,7 +44,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
4244 TrueColor: message.TrueColor,
4345 MaxOutputInFlight: uint32(message.MaxOutputInFlight),
4446 Env: append([]string(nil), message.Env...),
45- RemoteTarget: remote,
47+ SessionTarget: sessionTarget,
4648 EnvironmentPolicy: uint32(message.EnvironmentPolicy),
4749 ExactTarget: exact,
4850 PreferredTabId: string(message.PreferredTabID),
@@ -110,11 +112,11 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
110112 }
111113 hello.MaxOutputInFlight = window
112114 hello.Env = append([]string(nil), message.GetEnv()...)
113- remote, err := remoteTargetFromWire(message.GetRemoteTarget())
115+ sessionTarget, err := sessionAttachTargetFromWire(message.GetSessionTarget())
114116 if err != nil {
115117 return protocol.Hello{}, err
116118 }
117- hello.RemoteTarget = remote
119+ hello.SessionTarget = sessionTarget
118120 hello.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy())
119121 if err != nil {
120122 return protocol.Hello{}, err
@@ -203,11 +205,47 @@ func committedIdentityFromWire(message *wire.CommittedRouteIdentity) (protocol.C
203205 return identity, nil
204206 }
205207
208+func sessionAttachTargetToWire(target *protocol.SessionAttachTarget) (*wire.SessionAttachTarget, error) {
209+ if target == nil {
210+ return nil, nil
211+ }
212+ if err := target.Validate(); err != nil {
213+ return nil, err
214+ }
215+ message := &wire.SessionAttachTarget{SessionId: string(target.SessionID), LifecycleId: lifecycleToWire(target.LifecycleID), SessionName: target.SessionName, TabId: string(target.TabID), TabRawName: target.TabRawName, TabExpectedCount: uint32(target.TabExpectedCount), Stopped: target.Stopped}
216+ if target.TabIndex != protocol.NoTabIndex {
217+ message.TabIndex = proto.Int32(target.TabIndex)
218+ }
219+ return message, nil
220+}
221+
222+func sessionAttachTargetFromWire(message *wire.SessionAttachTarget) (*protocol.SessionAttachTarget, error) {
223+ if message == nil {
224+ return nil, nil
225+ }
226+ if message.GetTabExpectedCount() > math.MaxUint16 {
227+ return nil, protocol.ErrInvalidAttachTarget
228+ }
229+ lifecycle, err := lifecycleFromWire(message.GetLifecycleId())
230+ if err != nil {
231+ return nil, err
232+ }
233+ tabIndex := protocol.NoTabIndex
234+ if message.TabIndex != nil {
235+ tabIndex = message.GetTabIndex()
236+ }
237+ target := &protocol.SessionAttachTarget{SessionID: domain.SessionID(message.GetSessionId()), LifecycleID: lifecycle, SessionName: message.GetSessionName(), TabID: domain.TabStableID(message.GetTabId()), TabIndex: tabIndex, TabRawName: message.GetTabRawName(), TabExpectedCount: uint16(message.GetTabExpectedCount()), Stopped: message.GetStopped()}
238+ if err := target.Validate(); err != nil {
239+ return nil, err
240+ }
241+ return target, nil
242+}
243+
206244 func attachTargetToWire(message protocol.AttachTarget) (*wire.AttachTarget, error) {
207245 if err := protocol.ValidateAttachTarget(message); err != nil {
208246 return nil, err
209247 }
210- remote, err := remoteTargetToWire(message.RemoteTarget)
248+ sessionTarget, err := sessionAttachTargetToWire(message.SessionTarget)
211249 if err != nil {
212250 return nil, err
213251 }
@@ -216,7 +254,7 @@ func attachTargetToWire(message protocol.AttachTarget) (*wire.AttachTarget, erro
216254 Endpoint: message.Endpoint,
217255 Session: message.Session,
218256 Intent: uint32(message.Intent),
219- RemoteTarget: remote,
257+ SessionTarget: sessionTarget,
220258 EnvironmentPolicy: uint32(message.EnvironmentPolicy),
221259 ExactTarget: exactTargetToWire(message.ExactTarget),
222260 SamePeer: message.SamePeer,
@@ -238,11 +276,11 @@ func attachTargetFromWire(message *wire.AttachTarget) (protocol.AttachTarget, er
238276 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
239277 }
240278 target.Intent = intent
241- remote, err := remoteTargetFromWire(message.GetRemoteTarget())
279+ sessionTarget, err := sessionAttachTargetFromWire(message.GetSessionTarget())
242280 if err != nil {
243281 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
244282 }
245- target.RemoteTarget = remote
283+ target.SessionTarget = sessionTarget
246284 target.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy())
247285 if err != nil {
248286 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
M internal/adapters/sessionwire/proto_convert_test.go +24 -0
@@ -134,6 +134,30 @@ func TestProtoServerRoundTrips(t *testing.T) {
134134 }
135135 }
136136
137+func TestSessionAttachTargetFromWireRejectsInvalidRawName(t *testing.T) {
138+ lifecycle := lifecycleToWire(domain.SessionLifecycleID{1})
139+ for _, rawName := range []string{string(make([]byte, 257)), "bad\nname"} {
140+ _, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: rawName, TabExpectedCount: 1, Stopped: true})
141+ require.Error(t, err)
142+ }
143+}
144+
145+func TestSessionAttachTargetTabIndexPresence(t *testing.T) {
146+ lifecycle := lifecycleToWire(domain.SessionLifecycleID{1})
147+
148+ zero, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", TabIndex: proto.Int32(0), TabRawName: "first", TabExpectedCount: 1, Stopped: true})
149+ require.NoError(t, err)
150+ require.Equal(t, int32(0), zero.TabIndex)
151+
152+ absent, err := sessionAttachTargetFromWire(&wire.SessionAttachTarget{LifecycleId: lifecycle, SessionName: "work", Stopped: true})
153+ require.NoError(t, err)
154+ require.Equal(t, protocol.NoTabIndex, absent.TabIndex)
155+
156+ encoded, err := sessionAttachTargetToWire(zero)
157+ require.NoError(t, err)
158+ require.Zero(t, encoded.GetTabIndex())
159+}
160+
137161 // TestProtoWrongDirectionRejects proves a server payload in a client
138162 // envelope (and vice versa) fails before any mutation.
139163 func TestProtoWrongDirectionRejects(t *testing.T) {
M internal/adapters/sessionwire/server.go +66 -4
@@ -34,6 +34,14 @@ type serverConnection struct {
3434 preambleErr error
3535 deadline time.Time
3636
37+ // admission is the provisioned admission metadata the accepting side
38+ // stamped, or the zero value with hasAdmission=false for a connection built
39+ // by a legacy constructor that carried no admission. It is never mutated
40+ // after construction and is exposed only through SessionAdmission, which
41+ // returns a deep copy.
42+ admission ports.SessionAdmission
43+ hasAdmission bool
44+
3745 // preambleDone closes exactly once when the handshake has run, whether it
3846 // succeeded or failed. It backs the handshake completion seam a mux listener
3947 // exposes to the daemon; the absolute deadline is never restarted.
@@ -45,14 +53,18 @@ type serverConnection struct {
4553 hooks []func()
4654 }
4755
48-var _ ports.ServerConnection = (*serverConnection)(nil)
56+var (
57+ _ ports.ServerConnection = (*serverConnection)(nil)
58+ _ ports.SessionAdmissionProvider = (*serverConnection)(nil)
59+)
4960
5061 // NewServerConnection wraps one raw connection incarnation exactly once. The
5162 // server preamble runs on the first ReceiveClient, bounded by the handshake
5263 // deadline started here; the daemon's transport watcher closes the link on
5364 // timeout, which unblocks the preamble read. Prefer
5465 // NewServerConnectionWithDeadline when the caller already owns the absolute
55-// deadline, as the daemon-side mux listener does at Open admission.
66+// deadline, as the daemon-side mux listener does at Open admission. The
67+// returned connection carries no admission metadata.
5668 func NewServerConnection(raw wire.Transport) ports.ServerConnection {
5769 return NewServerConnectionWithDeadline(raw, time.Now().Add(protocol.HandshakeTimeout))
5870 }
@@ -63,15 +75,65 @@ func NewServerConnection(raw wire.Transport) ports.ServerConnection {
6375 // so a stream delayed in an accept queue spends that one budget instead of
6476 // starting a second one. The preamble still runs lazily on the first
6577 // ReceiveClient; a deadline already in the past fails it immediately. A zero
66-// deadline falls back to a fresh protocol.HandshakeTimeout from now.
78+// deadline falls back to a fresh protocol.HandshakeTimeout from now. The
79+// returned connection carries no admission metadata.
6780 func NewServerConnectionWithDeadline(raw wire.Transport, deadline time.Time) ports.ServerConnection {
81+ return newServerConnection(raw, deadline, ports.SessionAdmission{}, false)
82+}
83+
84+// NewServerConnectionWithAdmission wraps one raw connection incarnation exactly
85+// once with an absolute local handshake deadline and the provisioned admission
86+// metadata its accepting side stamped (the exact accepted policy and origin,
87+// plus the peer's declared purpose, admission variant, name, target, and
88+// bounded environment). The admission is stored as a deep copy and exposed only
89+// through SessionAdmission, which returns a fresh copy so a consumer can never
90+// mutate admitted authority. An entirely zero admission is reported as ok=false,
91+// exactly as the legacy constructors do, so a caller cannot accidentally
92+// publish an unvalidated admission.
93+func NewServerConnectionWithAdmission(raw wire.Transport, deadline time.Time, admission ports.SessionAdmission) ports.ServerConnection {
94+ return newServerConnection(raw, deadline, admission, !admissionAbsent(admission))
95+}
96+
97+// admissionAbsent reports whether an admission is entirely unset. It is the
98+// presence test for NewServerConnectionWithAdmission: an admission that names no
99+// origin, policy, purpose, or payload is indistinguishable from the legacy
100+// constructors' absent metadata and is reported as absent rather than trusted.
101+func admissionAbsent(admission ports.SessionAdmission) bool {
102+ return admission.Origin == 0 && admission.Policy == (ports.BrokerPolicy{}) &&
103+ admission.Purpose == 0 && admission.Admission == 0 && admission.Name == "" &&
104+ admission.Target == (protocol.ExactSessionTarget{}) && len(admission.Env) == 0
105+}
106+
107+// newServerConnection is the shared constructor: it clones the admission and
108+// records whether it is present, so every exported constructor funnels through
109+// one place that fixes the deadline and allocates the completion signal.
110+func newServerConnection(raw wire.Transport, deadline time.Time, admission ports.SessionAdmission, hasAdmission bool) ports.ServerConnection {
68111 if raw == nil {
69112 return nil
70113 }
71114 if deadline.IsZero() {
72115 deadline = time.Now().Add(protocol.HandshakeTimeout)
73116 }
74- return &serverConnection{raw: raw, ceilings: defaultProtoCeilings(), deadline: deadline, preambleDone: make(chan struct{})}
117+ return &serverConnection{
118+ raw: raw,
119+ ceilings: defaultProtoCeilings(),
120+ deadline: deadline,
121+ admission: admission.Clone(),
122+ hasAdmission: hasAdmission,
123+ preambleDone: make(chan struct{}),
124+ }
125+}
126+
127+// SessionAdmission returns a deep copy of the provisioned admission metadata
128+// stamped on this connection, implementing ports.SessionAdmissionProvider. It
129+// reports ok=false for a connection built by a constructor that carried no
130+// admission, so a consumer distinguishes an absent admission from an admitted
131+// one instead of trusting a zero value.
132+func (c *serverConnection) SessionAdmission() (ports.SessionAdmission, bool) {
133+ if c == nil || !c.hasAdmission {
134+ return ports.SessionAdmission{}, false
135+ }
136+ return c.admission.Clone(), true
75137 }
76138
77139 // HandshakeDeadline returns the absolute local deadline of the session
A internal/adapters/sessionwire/server_admission_test.go +115 -0
@@ -0,0 +1,115 @@
1+package sessionwire
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+)
13+
14+func wireTestPolicy() ports.BrokerPolicy {
15+ return ports.BrokerPolicy{
16+ ProtocolVersion: protocol.Version,
17+ CatalogSchemaVersion: 3,
18+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
19+ Transport: "unix-mux",
20+ Trust: "same-user",
21+ Launch: "explicit",
22+ Isolation: "per-user",
23+ }
24+}
25+
26+func wireTestAdmission() ports.SessionAdmission {
27+ return ports.SessionAdmission{
28+ Origin: ports.SessionOriginLocal,
29+ Policy: wireTestPolicy(),
30+ Purpose: ports.BrokerStreamAttachment,
31+ Admission: ports.BrokerAdmissionExact,
32+ Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{4, 5, 6}, SessionName: "work"},
33+ Env: []string{"TERM=xterm-256color"},
34+ }
35+}
36+
37+// TestServerConnectionAdmissionProviderPreservedWithoutWireChange proves the
38+// admission-aware constructor stores the provisioned metadata and exposes it
39+// through the optional provider, while a successful typed round trip over the
40+// same connection is byte-for-byte the ordinary session traffic: the admission
41+// never leaks onto the session wire.
42+func TestServerConnectionAdmissionProviderPreservedWithoutWireChange(t *testing.T) {
43+ raw := &scriptedTransport{recv: mustServerPreambleQueue(t, mustEncodeClient(t, protocol.Ping{}))}
44+ deadline := time.Now().Add(protocol.HandshakeTimeout)
45+ connection := NewServerConnectionWithAdmission(raw, deadline, wireTestAdmission())
46+
47+ provider, ok := connection.(ports.SessionAdmissionProvider)
48+ require.True(t, ok, "the admission-aware connection implements the optional provider")
49+ admission, ok := provider.SessionAdmission()
50+ require.True(t, ok)
51+ require.Equal(t, wireTestAdmission(), admission)
52+ require.NoError(t, admission.Validate())
53+
54+ // A typed exchange on the same connection succeeds and the sent application
55+ // frame is the ordinary Pong encoding: the admission is not part of the
56+ // session wire. (The preamble response precedes it.)
57+ message, err := connection.ReceiveClient()
58+ require.NoError(t, err)
59+ require.Equal(t, protocol.Ping{}, message)
60+ require.NoError(t, connection.SendServer(protocol.Pong{}))
61+ require.Equal(t, 2, raw.sentLen())
62+ got, err := DecodeServerEnvelope(raw.sentPayload(1))
63+ require.NoError(t, err)
64+ require.Equal(t, protocol.Pong{}, got)
65+}
66+
67+// TestServerConnectionAdmissionProviderIsDefensiveCopy proves the provider hands
68+// out an independent copy on every read: a consumer that mutates what it holds
69+// never alters the connection's stored admission or a sibling's view.
70+func TestServerConnectionAdmissionProviderIsDefensiveCopy(t *testing.T) {
71+ raw := &scriptedTransport{}
72+ connection := NewServerConnectionWithAdmission(raw, time.Now().Add(protocol.HandshakeTimeout), wireTestAdmission())
73+ provider := connection.(ports.SessionAdmissionProvider)
74+
75+ mutated, ok := provider.SessionAdmission()
76+ require.True(t, ok)
77+ mutated.Env[0] = "TERM=mutated"
78+ mutated.Policy.Trust = "mutated"
79+ mutated.Target.SessionName = "mutated"
80+
81+ reread, ok := provider.SessionAdmission()
82+ require.True(t, ok)
83+ require.Equal(t, wireTestAdmission(), reread)
84+
85+ left, _ := provider.SessionAdmission()
86+ right, _ := provider.SessionAdmission()
87+ left.Env[0] = "TERM=left"
88+ require.Equal(t, "TERM=xterm-256color", right.Env[0])
89+}
90+
91+// TestServerConnectionAdmissionAbsentForLegacyConstructors proves the legacy
92+// constructors carry no admission metadata: a consumer that asserts the
93+// provider is told ok=false rather than trusted with a zero admission.
94+func TestServerConnectionAdmissionAbsentForLegacyConstructors(t *testing.T) {
95+ for _, tc := range []struct {
96+ name string
97+ build func() ports.ServerConnection
98+ }{
99+ {name: "NewServerConnection", build: func() ports.ServerConnection { return NewServerConnection(&scriptedTransport{}) }},
100+ {name: "NewServerConnectionWithDeadline", build: func() ports.ServerConnection {
101+ return NewServerConnectionWithDeadline(&scriptedTransport{}, time.Now().Add(protocol.HandshakeTimeout))
102+ }},
103+ {name: "NewServerConnectionWithAdmission zero", build: func() ports.ServerConnection {
104+ return NewServerConnectionWithAdmission(&scriptedTransport{}, time.Now().Add(protocol.HandshakeTimeout), ports.SessionAdmission{})
105+ }},
106+ } {
107+ t.Run(tc.name, func(t *testing.T) {
108+ provider, ok := tc.build().(ports.SessionAdmissionProvider)
109+ require.True(t, ok)
110+ admission, present := provider.SessionAdmission()
111+ require.False(t, present)
112+ require.Equal(t, ports.SessionAdmission{}, admission)
113+ })
114+ }
115+}
M internal/adapters/uidriver/request.go +1 -6
@@ -163,12 +163,7 @@ func decodeExpect(data []byte) (ports.UIExpect, error) {
163163 result.TextContains = &text
164164 case "status":
165165 var status ports.UIPresentationStatus
166- if json.Unmarshal(raw, &status) != nil {
167- return result, invalidRequest()
168- }
169- switch status {
170- case ports.UIStatusAttached, ports.UIStatusTransitioning, ports.UIStatusReconnecting, ports.UIStatusDetached:
171- default:
166+ if json.Unmarshal(raw, &status) != nil || !status.Valid() {
172167 return result, invalidRequest()
173168 }
174169 result.Status = &status
M internal/adapters/uidriver/request_test.go +6 -0
@@ -23,6 +23,12 @@ func TestDecodeRequestStrictOperationSchemas(t *testing.T) {
2323 {"duplicate", `{"version":1,"id":1,"id":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest},
2424 {"escaped duplicate", `{"version":1,"id":1,"\u0069d":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest},
2525 {"nested duplicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"attached","status":"detached"}}`, ports.UIErrInvalidRequest},
26+ {"picker predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"picker"}}`, ""},
27+ {"connecting predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"connecting"}}`, ""},
28+ {"removed detached predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"detached"}}`, ports.UIErrInvalidRequest},
29+ {"removed reconnecting predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"reconnecting"}}`, ports.UIErrInvalidRequest},
30+ {"removed transitioning predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"transitioning"}}`, ports.UIErrInvalidRequest},
31+ {"unknown status predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"terminating"}}`, ports.UIErrInvalidRequest},
2632 {"nested unknown", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"focus":{"tab_id":"t","pane_id":"p","extra":"x"}}}`, ports.UIErrInvalidRequest},
2733 {"unknown predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"regex":".*"}}`, ports.UIErrInvalidRequest},
2834 {"empty predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{}}`, ports.UIErrInvalidRequest},
M internal/adapters/uiterm/terminal.go +19 -10
@@ -82,7 +82,7 @@ func newTerminal(ctx context.Context, geometry domain.Geometry, attachmentHandle
8282 screen: screen,
8383 mirror: allowOversized,
8484 geometry: geometry,
85- context: ports.UIContext{AttachmentHandle: attachmentHandle, Generation: 1},
85+ context: ports.UIContext{AttachmentHandle: attachmentHandle},
8686 available: true,
8787 captureTooLarge: oversized,
8888 changes: make(chan struct{}, 1),
@@ -195,12 +195,7 @@ func (t *Terminal) BeginOutput(context ports.UIContext) {
195195 t.mu.Lock()
196196 defer t.mu.Unlock()
197197 if t.txDepth == 0 {
198- if context.AttachmentHandle == "" {
199- context.AttachmentHandle = t.context.AttachmentHandle
200- }
201- if context.Generation == 0 {
202- context.Generation = t.context.Generation
203- }
198+ context = t.completeContextLocked(context)
204199 t.context = context
205200 t.txFailed = false
206201 }
@@ -234,15 +229,29 @@ func (t *Terminal) PublishContext(context ports.UIContext) error {
234229 if t.closed || !t.available {
235230 return ports.ErrUIUnavailable
236231 }
232+ t.context = t.completeContextLocked(context)
233+ t.publishLocked()
234+ return nil
235+}
236+
237+// completeContextLocked applies the published-context shape rule for one
238+// published context. Only an Attached presentation may carry a session
239+// identity, a committed output boundary, or an actionable generation, and only
240+// it inherits a missing handle or generation from the previous context. Picker
241+// and Connecting are normalised to the handle and status they state, so an
242+// unattached publication can never expose an old attachment's identity or a
243+// generation no attachment committed.
244+func (t *Terminal) completeContextLocked(context ports.UIContext) ports.UIContext {
245+ if context.Status != ports.UIStatusAttached {
246+ return ports.UIContext{AttachmentHandle: context.AttachmentHandle, Status: context.Status}
247+ }
237248 if context.AttachmentHandle == "" {
238249 context.AttachmentHandle = t.context.AttachmentHandle
239250 }
240251 if context.Generation == 0 {
241252 context.Generation = t.context.Generation
242253 }
243- t.context = context
244- t.publishLocked()
245- return nil
254+ return context
246255 }
247256
248257 func (t *Terminal) Snapshot() (ports.UISnapshot, error) {
M internal/adapters/uiterm/terminal_test.go +38 -0
@@ -218,3 +218,41 @@ func newTestTerminal(t *testing.T, columns, rows int) *Terminal {
218218 t.Cleanup(terminal.Close)
219219 return terminal
220220 }
221+
222+// TestTerminalUnattachedPublicationKeepsStatedIdentity pins the published-context
223+// shape rule at the terminal: only an Attached publication inherits a missing
224+// handle or generation. A Picker or Connecting publication is recorded exactly
225+// as stated, so an unattached capture never shows an old attachment's handle or
226+// a generation no attachment committed.
227+func TestTerminalUnattachedPublicationKeepsStatedIdentity(t *testing.T) {
228+ terminal := newTestTerminal(t, 4, 2)
229+
230+ // Establish an attached context with a real handle and generation.
231+ if err := terminal.PublishContext(ports.UIContext{AttachmentHandle: "attached-handle", Generation: 6, Status: ports.UIStatusAttached}); err != nil {
232+ t.Fatal(err)
233+ }
234+
235+ if err := terminal.PublishContext(ports.UIContext{AttachmentHandle: "picker-handle", Status: ports.UIStatusPicker}); err != nil {
236+ t.Fatal(err)
237+ }
238+ snapshot, err := terminal.Snapshot()
239+ if err != nil {
240+ t.Fatal(err)
241+ }
242+ if snapshot.Context.AttachmentHandle != "picker-handle" || snapshot.Context.Generation != 0 || snapshot.Context.Status != ports.UIStatusPicker {
243+ t.Fatalf("picker context = %#v", snapshot.Context)
244+ }
245+
246+ // An Attached publication with a missing handle and generation still inherits
247+ // them, exactly as the attachment foreground relies on.
248+ if err := terminal.PublishContext(ports.UIContext{Status: ports.UIStatusAttached, OutputState: 3}); err != nil {
249+ t.Fatal(err)
250+ }
251+ snapshot, err = terminal.Snapshot()
252+ if err != nil {
253+ t.Fatal(err)
254+ }
255+ if snapshot.Context.AttachmentHandle != "picker-handle" || snapshot.Context.Generation != 0 {
256+ t.Fatalf("attached continuity context = %#v", snapshot.Context)
257+ }
258+}
D internal/app/attach_preflight.go +0 -142
@@ -1,142 +0,0 @@
1-package app
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "log/slog"
8- "os"
9-
10- "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/protocol"
12- "github.com/bnema/vev/internal/usecase/confirm"
13- "github.com/bnema/vev/pkg/rawterm"
14-)
15-
16-const maxAttachTargetHandoffs = 32
17-
18-// resolveMissingSessionAttach decides the attach intent for a direct local
19-// attach before any client attempt runs. When the session exists, or when
20-// existence cannot be determined, it keeps IntentAttach and the daemon
21-// rejection (if any) surfaces unchanged. When the session is absent it
22-// offers creation on an interactive console and returns IntentNew on
23-// confirmation; decline keeps IntentAttach, and a cancelled wait surfaces
24-// the cancellation error.
25-//
26-// The check runs before the client owns any console reader, so the create
27-// prompt is always the sole stdin consumer: no failed attempt can leave a
28-// blocked input-pump read behind to steal the answer. Provenance comes from
29-// the listing itself, so no error-text matching is needed and in-client
30-// navigation handoffs can never trigger creation of the requested session.
31-func resolveMissingSessionAttach(ctx context.Context, name string, deps runAttachDeps, log *slog.Logger) (uint8, error) {
32- switch exists, err := sessionExists(ctx, name, deps); {
33- case err != nil:
34- if log != nil {
35- log.Debug("missing-session preflight unavailable; proceeding with attach", "err", err)
36- }
37- case !exists:
38- return confirmMissingSessionCreate(ctx, name, deps)
39- }
40- return protocol.IntentAttach, nil
41-}
42-
43-// terminalIsInteractive reports whether the prompt can read its answer from
44-// a console. The client terminal's input has to be a real terminal file:
45-// pipes, redirected files, and detached readers are not consoles, so the
46-// prompt never blocks on a stream nobody reads from the keyboard.
47-func terminalIsInteractive(terminal ports.Terminal) bool {
48- if terminal == nil {
49- return false
50- }
51- file, ok := terminal.In().(*os.File)
52- return ok && rawterm.IsTerminal(int(file.Fd()))
53-}
54-
55-// consoleProbe returns the console probe used by the create prompt.
56-// Production probes the client terminal's input; tests inject the decision
57-// so the prompt matrix runs without owning a real TTY.
58-func consoleProbe(deps runAttachDeps) func(ports.Terminal) bool {
59- if deps.interactiveConsole != nil {
60- return deps.interactiveConsole
61- }
62- return terminalIsInteractive
63-}
64-
65-// flushingWriter pushes every prompt write to the console before the
66-// confirmer blocks on the answer. Terminal output is buffered, so without
67-// this flush the user waits in front of a blank console until the client
68-// repaints.
69-type flushingWriter struct {
70- out io.Writer
71- flush func() error
72-}
73-
74-func (w flushingWriter) Write(data []byte) (int, error) {
75- written, err := w.out.Write(data)
76- if err != nil {
77- return written, err
78- }
79- if err := w.flush(); err != nil {
80- return written, err
81- }
82- return written, nil
83-}
84-
85-// confirmMissingSessionCreate prompts to create an absent session and maps
86-// the answer to the attach intent: IntentNew on confirmation, IntentAttach
87-// on decline. A cancelled wait surfaces the cancellation error.
88-// Non-interactive consoles, a declined answer, or unreadable input keep
89-// IntentAttach. Only y/yes answers confirm; empty, no, and unknown answers
90-// decline.
91-func confirmMissingSessionCreate(ctx context.Context, name string, deps runAttachDeps) (uint8, error) {
92- if name == "" || ctx.Err() != nil {
93- return protocol.IntentAttach, nil
94- }
95- terminal := clientTerminal(deps)
96- if !consoleProbe(deps)(terminal) {
97- return protocol.IntentAttach, nil
98- }
99- in, out := terminal.In(), terminal.Out()
100- if in == nil || out == nil {
101- return protocol.IntentAttach, nil
102- }
103- prompt := confirm.NewConfirmer(in, flushingWriter{out: out, flush: terminal.Flush})
104- create, err := prompt.ConfirmContext(ctx, fmt.Sprintf("vev: session %q doesn't exist, want to create and attach to it?", name))
105- if err != nil {
106- if ctxErr := ctx.Err(); ctxErr != nil {
107- return protocol.IntentAttach, ctxErr
108- }
109- // A console that cannot show the question must not consume an
110- // answer: keep the plain attach path and let the daemon decide.
111- return protocol.IntentAttach, nil
112- }
113- if !create {
114- return protocol.IntentAttach, nil
115- }
116- if err := ctx.Err(); err != nil {
117- return protocol.IntentAttach, err
118- }
119- return protocol.IntentNew, nil
120-}
121-
122-// sessionExists reports whether a directly attached local session exists.
123-// An error means existence could not be determined.
124-func sessionExists(ctx context.Context, name string, deps runAttachDeps) (bool, error) {
125- if name == "" {
126- return false, nil
127- }
128- dial := deps.localDialer
129- if dial == nil {
130- dial = defaultLocalDialer
131- }
132- sessions, err := listSessionsWithDialer(ctx, dial().Dial)
133- if err != nil {
134- return false, err
135- }
136- for _, session := range sessions {
137- if session.Name == name {
138- return true, nil
139- }
140- }
141- return false, nil
142-}
A internal/app/broker_client.go +535 -0
@@ -0,0 +1,535 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "sync"
9+
10+ "github.com/bnema/vev/internal/adapters/clock"
11+ "github.com/bnema/vev/internal/adapters/term"
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/domain/terminalcap"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/protocol"
16+ "github.com/bnema/vev/internal/protocol/catalogue"
17+ "github.com/bnema/vev/internal/usecase/client"
18+)
19+
20+// Common broker client composition (Plan 001 P7.4a).
21+//
22+// runBrokerClient is the single terminal composition every frontend promotes:
23+// the sandbox terminal harness, the sandbox UI-driver harness, and the ordinary
24+// production terminal attach all build one brokerClientConfig and delegate
25+// here. It constructs exactly one client.Picker and exactly one
26+// client.Supervisor and hands the whole run to Supervisor.Run, so no frontend
27+// keeps a parallel picker, supervisor, attachment stream, or daemon dialer.
28+//
29+// The composition owns only what the supervisor cannot: the connector (or the
30+// production connect-or-spawn behind it), the terminal, the clock, the optional
31+// UI publication/action seam, the one-shot initial navigation intent (or the
32+// single-shot resolver that translates a CLI target into it), the copied
33+// attachment environment, the optional lifecycle-action channel, and the
34+// presentation callbacks. It never opens a stream, resolves a key, or drives
35+// picker input itself.
36+
37+// brokerClientConfig is the common client configuration shared by every
38+// frontend. Connector, Terminal, and Clock are required (a nil Clock falls back
39+// to the process clock); the remaining fields are optional and keep the
40+// supervisor defaults when they are left zero.
41+type brokerClientConfig struct {
42+ Connector ports.BrokerConnector
43+ Terminal ports.Terminal
44+ Clock ports.Clock
45+ UI *client.UI
46+ // InitialNavigation is the one-shot intent decided before the connection.
47+ // Its zero value is InitialNavigationPicker, which opens nothing.
48+ InitialNavigation client.InitialNavigation
49+ // ResolveInitialNavigation translates a composition-owned CLI target (an
50+ // attach name or a remote endpoint) into an exact navigation identity
51+ // against the first committed broker publication. It is mutually exclusive
52+ // with a non-zero InitialNavigation.
53+ ResolveInitialNavigation client.InitialNavigationResolver
54+ // AttachmentEnvironment is the copied process context of each attachment
55+ // Hello. It is never read from the broker process.
56+ AttachmentEnvironment client.AttachmentEnvironment
57+ // SessionEnvironment is the process snapshot for session creation. The
58+ // production frontends capture one LocalCLI snapshot before NewSupervisor;
59+ // offline and test compositions pass an explicit valid value (LocalCLI
60+ // with empty Env/Cwd unless the test asserts env). A zero value falls
61+ // back to the production LocalCLI snapshot inside runBrokerClient so the
62+ // ordinary terminal path keeps no second capture.
63+ SessionEnvironment client.SessionEnvironment
64+ // LifecycleActions carries explicit attachment lifecycle requests. A nil
65+ // channel disables external lifecycle actions.
66+ LifecycleActions <-chan client.AttachmentLifecycleAction
67+ // OnState observes every supervisor state. Optional.
68+ OnState func(client.State)
69+ // OnFailure surfaces one typed attachment or connectivity failure without
70+ // ending the run. Optional.
71+ OnFailure func(error)
72+ // OnLifecycle reports typed, bounded lifecycle transitions. Optional.
73+ OnLifecycle func(client.LifecycleNotice)
74+}
75+
76+// brokerClientPresentation is the single terminal writer of one broker client
77+// run. Rendering the picker and reporting a bounded failure both go through it,
78+// so a failure notice can never interleave bytes with a picker frame.
79+type brokerClientPresentation struct {
80+ terminal ports.Terminal
81+ picker *client.Picker
82+ // ui is the run's UI owner. When set, the picker frame is written inside the
83+ // terminal's UI output transaction under the Picker presentation, exactly like
84+ // daemon output, so the committed snapshot and the painted bytes describe the
85+ // same unattached state. Optional: a test may paint without an observation
86+ // channel.
87+ ui *client.UI
88+ onState func(client.State)
89+
90+ mu sync.Mutex
91+}
92+
93+// Render observes the state and paints the picker frame and notice while the
94+// shared client.PickerPresentation fence admits the presentation. That is
95+// exactly PresentPicker: an admitted attachment foreground owns the same
96+// terminal writer before its attached marker and initial publication, and a
97+// terminating process is leaving the terminal, so neither is ever painted over.
98+// Picker is therefore never painted while the supervisor presents Connecting.
99+//
100+// The frame is committed through the terminal's UI output transaction under the
101+// Picker presentation, so the published context and the painted bytes are one
102+// coherent unattached state; Picker carries the run's handle with no session
103+// identity and no actionable generation. An unavailable observation channel
104+// leaves the frame written and flushed, exactly like the attachment foreground.
105+//
106+// ResizeEvents has exactly one consumer for the whole supervisor run (its
107+// attachment geometry collector); rendering samples Geometry here at render
108+// time instead of competing for that single event channel, so a repaint always
109+// observes the latest size.
110+func (p *brokerClientPresentation) Render(state client.State) {
111+ if p == nil {
112+ return
113+ }
114+ if p.onState != nil {
115+ p.onState(state)
116+ }
117+ if !client.PickerPresentation(state) {
118+ return
119+ }
120+ geometry, err := p.terminal.Geometry()
121+ if err != nil {
122+ return
123+ }
124+ frame := p.picker.Render(geometry.Size)
125+ notice := p.picker.RenderNotice(geometry.Size)
126+ if len(frame) == 0 && len(notice) == 0 {
127+ return
128+ }
129+ p.mu.Lock()
130+ defer p.mu.Unlock()
131+ writer := p.terminal.Out()
132+ if writer == nil {
133+ return
134+ }
135+ transaction, _ := p.terminal.(ports.UIOutputTransaction)
136+ if transaction != nil {
137+ transaction.BeginOutput(p.pickerContext())
138+ if err := transaction.PublishContext(p.pickerContext()); err != nil && !errors.Is(err, ports.ErrUIUnavailable) {
139+ transaction.EndOutput(false)
140+ return
141+ }
142+ }
143+ success := true
144+ if len(frame) != 0 {
145+ if _, writeErr := writer.Write(frame); writeErr != nil {
146+ success = false
147+ }
148+ }
149+ if success && len(notice) != 0 {
150+ if _, writeErr := writer.Write(notice); writeErr != nil {
151+ success = false
152+ }
153+ }
154+ if success && p.terminal.Flush() != nil {
155+ success = false
156+ }
157+ if transaction != nil {
158+ transaction.EndOutput(success)
159+ }
160+}
161+
162+// pickerContext is the Picker presentation of this run: the stable UI handle and
163+// the status alone. It is the shape every unattached publication uses, so a
164+// capture of the painted picker never shows an old attachment's session
165+// identity or generation.
166+func (p *brokerClientPresentation) pickerContext() ports.UIContext {
167+ context := ports.UIContext{Status: ports.UIStatusPicker}
168+ if p.ui != nil {
169+ context.AttachmentHandle = p.ui.Handle()
170+ }
171+ return context
172+}
173+
174+// Failure reports one bounded attachment or connectivity failure on the same
175+// terminal writer. The supervisor already reports a typed, bounded lifecycle
176+// notice through the picker, so this is the complementary diagnostic: one
177+// bounded line under the shared writer lock, never a raw cause chain and never
178+// a competing terminal owner.
179+func (p *brokerClientPresentation) Failure(err error) {
180+ if p == nil || err == nil {
181+ return
182+ }
183+ text := err.Error()
184+ if len(text) > ports.BrokerMaxErrorBytes {
185+ text = text[:ports.BrokerMaxErrorBytes]
186+ }
187+ p.mu.Lock()
188+ defer p.mu.Unlock()
189+ writer := p.terminal.Out()
190+ if writer == nil {
191+ return
192+ }
193+ if _, writeErr := writer.Write([]byte("\r\nvev: " + text + "\r\n")); writeErr != nil {
194+ return
195+ }
196+ _ = p.terminal.Flush()
197+}
198+
199+// runBrokerClient builds exactly one picker and one supervisor over cfg and
200+// delegates the whole run to the supervisor. It returns the supervisor's
201+// terminal cause: a clean terminal EOF, an explicit exit, or the run's
202+// cancellation, joined with any failure the supervisor classified as terminal.
203+func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
204+ if cfg.Terminal == nil {
205+ return errors.New("vev: broker client requires a terminal")
206+ }
207+ clk := cfg.Clock
208+ if clk == nil {
209+ clk = clock.New()
210+ }
211+ sessionEnv := cfg.SessionEnvironment
212+ if sessionEnv.Provenance == client.SessionEnvironmentUnspecified {
213+ // Production fallback: capture one LocalCLI process snapshot before
214+ // NewSupervisor so ordinary terminal attach keeps no second capture.
215+ sessionEnv = terminalSessionEnvironment()
216+ }
217+ attachmentEnv := cfg.AttachmentEnvironment
218+ if sessionEnv.Cwd != "" {
219+ attachmentEnv.Cwd = sessionEnv.Cwd
220+ }
221+ picker := client.NewPicker(clk, 0)
222+ presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState}
223+ supervisor, err := client.NewSupervisor(client.SupervisorConfig{
224+ Connector: cfg.Connector,
225+ Terminal: cfg.Terminal,
226+ Clock: clk,
227+ UI: cfg.UI,
228+ Picker: picker,
229+ InitialNavigation: cfg.InitialNavigation,
230+ ResolveInitialNavigation: cfg.ResolveInitialNavigation,
231+ AttachmentEnvironment: attachmentEnv,
232+ SessionEnvironment: sessionEnv,
233+ LifecycleActions: cfg.LifecycleActions,
234+ Render: presentation.Render,
235+ Notify: func(_ client.State, err error) {
236+ if cfg.OnFailure != nil {
237+ cfg.OnFailure(err)
238+ return
239+ }
240+ presentation.Failure(err)
241+ },
242+ NotifyLifecycle: cfg.OnLifecycle,
243+ })
244+ if err != nil {
245+ return err
246+ }
247+ return supervisor.Run(ctx)
248+}
249+
250+// localEphemeralNavigation is the no-argument product intent: create one
251+// ephemeral session on the broker-owned local daemon. It is decided before the
252+// connection, so it carries no epoch and needs no resolver.
253+func localEphemeralNavigation() client.InitialNavigation {
254+ return client.InitialNavigation{
255+ Kind: client.InitialNavigationCreateEphemeral,
256+ Destination: ports.BrokerEndpointFence{Local: true},
257+ }
258+}
259+
260+// errTerminalNavigationUnresolved reports that a CLI target could not be
261+// translated into the exact identity a snapshot carries. It is a local refusal:
262+// no destination was dialed, so the supervisor surfaces it as a bounded
263+// selection-unavailable notice and returns to the picker instead of attaching a
264+// fallback or creating implicitly.
265+var errTerminalNavigationUnresolved = errors.New("vev: attach target is not in the broker catalogue")
266+
267+// terminalBrokerNavigation translates the parsed terminal CLI intent into the
268+// closed initial-navigation union. An intent that is fully determined before
269+// the connection (local creation) is a value; an intent whose exact identity
270+// only exists in a broker publication (`attach`, and any remote target) is a
271+// single-shot resolver that runs once against the first committed snapshot.
272+//
273+// The translation never invents identity: a remote destination is only ever the
274+// complete registration a snapshot carries, and an exact target is only ever
275+// the lifecycle/name pair the snapshot publishes. A target the snapshot does not
276+// carry is a refusal, never an implicit create and never a same-name fallback.
277+func terminalBrokerNavigation(intent uint8, name, remoteTarget string) (client.InitialNavigation, client.InitialNavigationResolver, error) {
278+ if remoteTarget == "" {
279+ switch intent {
280+ case protocol.IntentEphemeral:
281+ return localEphemeralNavigation(), nil, nil
282+ case protocol.IntentNew:
283+ if err := domain.ValidateSessionName(name); err != nil {
284+ return client.InitialNavigation{}, nil, err
285+ }
286+ return client.InitialNavigation{
287+ Kind: client.InitialNavigationCreateNamed,
288+ Destination: ports.BrokerEndpointFence{Local: true},
289+ Name: name,
290+ }, nil, nil
291+ case protocol.IntentAttach:
292+ if err := domain.ValidateSessionName(name); err != nil {
293+ return client.InitialNavigation{}, nil, err
294+ }
295+ return client.InitialNavigation{}, localExactAttachResolver(name), nil
296+ default:
297+ return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)
298+ }
299+ }
300+ if err := domain.ValidateRemoteHostTarget(remoteTarget); err != nil {
301+ return client.InitialNavigation{}, nil, err
302+ }
303+ switch intent {
304+ case protocol.IntentEphemeral:
305+ // `attach user@host` without a session keeps its product behavior: a
306+ // fresh ephemeral session on that remote daemon.
307+ return client.InitialNavigation{}, remoteEphemeralCreateResolver(remoteTarget), nil
308+ case protocol.IntentNew:
309+ if err := domain.ValidateSessionName(name); err != nil {
310+ return client.InitialNavigation{}, nil, err
311+ }
312+ return client.InitialNavigation{}, remoteNamedCreateResolver(remoteTarget, name), nil
313+ case protocol.IntentAttach:
314+ if err := domain.ValidateSessionName(name); err != nil {
315+ return client.InitialNavigation{}, nil, err
316+ }
317+ return client.InitialNavigation{}, remoteExactAttachResolver(remoteTarget, name), nil
318+ default:
319+ return client.InitialNavigation{}, nil, usagef("unsupported attach intent %d", intent)
320+ }
321+}
322+
323+// localExactAttachResolver resolves one local session name to its exact
324+// lifecycle in the committed publication. `attach <name>` is a target
325+// translation only: a name the local observation does not carry is refused, so
326+// the client returns to the picker with a notice instead of creating a session
327+// implicitly.
328+func localExactAttachResolver(name string) client.InitialNavigationResolver {
329+ return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
330+ observation, ok := localBrokerObservation(snapshot)
331+ if !ok {
332+ return client.InitialNavigation{}, fmt.Errorf("%w: no local daemon observation", errTerminalNavigationUnresolved)
333+ }
334+ target, ok := brokerObservationExactTarget(observation, name)
335+ if !ok {
336+ return client.InitialNavigation{}, fmt.Errorf("%w: %q", errTerminalNavigationUnresolved, name)
337+ }
338+ return client.InitialNavigation{
339+ Kind: client.InitialNavigationAttachExact,
340+ Epoch: snapshot.Epoch,
341+ Destination: ports.BrokerEndpointFence{Local: true},
342+ Target: target,
343+ }, nil
344+ }
345+}
346+
347+// remoteExactAttachResolver resolves one remote session name to its exact
348+// lifecycle under the endpoint's committed registration. The destination is the
349+// complete observed registration (endpoint, incarnation, and generation), never
350+// a bare hostname and never an invented daemon identity; an endpoint the broker
351+// does not carry is refused rather than dialed.
352+func remoteExactAttachResolver(endpoint, name string) client.InitialNavigationResolver {
353+ return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
354+ observation, ok := snapshot.Find(endpoint)
355+ if !ok {
356+ return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
357+ }
358+ target, ok := brokerObservationExactTarget(observation, name)
359+ if !ok {
360+ return client.InitialNavigation{}, fmt.Errorf("%w: %q on %q", errTerminalNavigationUnresolved, name, endpoint)
361+ }
362+ return client.InitialNavigation{
363+ Kind: client.InitialNavigationAttachExact,
364+ Epoch: snapshot.Epoch,
365+ Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
366+ Target: target,
367+ }, nil
368+ }
369+}
370+
371+func remoteNamedCreateResolver(endpoint, name string) client.InitialNavigationResolver {
372+ return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
373+ observation, ok := snapshot.Find(endpoint)
374+ if !ok {
375+ return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
376+ }
377+ return client.InitialNavigation{
378+ Kind: client.InitialNavigationCreateNamed,
379+ Epoch: snapshot.Epoch,
380+ Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
381+ Name: name,
382+ }, nil
383+ }
384+}
385+
386+// remoteEphemeralCreateResolver resolves `attach user@host` (no session) into a
387+// remote ephemeral creation fenced to the endpoint's committed registration and
388+// the epoch of the snapshot that resolved it.
389+func remoteEphemeralCreateResolver(endpoint string) client.InitialNavigationResolver {
390+ return func(snapshot ports.BrokerSnapshot) (client.InitialNavigation, error) {
391+ observation, ok := snapshot.Find(endpoint)
392+ if !ok {
393+ return client.InitialNavigation{}, fmt.Errorf("%w: %q is not a configured broker host", errTerminalNavigationUnresolved, endpoint)
394+ }
395+ return client.InitialNavigation{
396+ Kind: client.InitialNavigationCreateEphemeral,
397+ Epoch: snapshot.Epoch,
398+ Destination: ports.BrokerEndpointFence{Registration: observation.Registration},
399+ }, nil
400+ }
401+}
402+
403+// localBrokerObservation returns the single local daemon observation of one
404+// publication.
405+func localBrokerObservation(snapshot ports.BrokerSnapshot) (ports.BrokerDaemonObservation, bool) {
406+ for _, observation := range snapshot.Daemons {
407+ if observation.Local {
408+ return observation, true
409+ }
410+ }
411+ return ports.BrokerDaemonObservation{}, false
412+}
413+
414+// brokerObservationExactTarget resolves one session name to the exact
415+// lifecycle/name pair the observation publishes. A broken session is refused:
416+// its identity is known but not attachable, so an explicit attempt would only be
417+// a guaranteed refusal.
418+func brokerObservationExactTarget(observation ports.BrokerDaemonObservation, name string) (protocol.ExactSessionTarget, bool) {
419+ for _, session := range observation.Sessions {
420+ if session.Name != name || session.State == catalogue.RemoteCatalogSessionBroken {
421+ continue
422+ }
423+ target := protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name}
424+ if err := target.Validate(); err != nil {
425+ return protocol.ExactSessionTarget{}, false
426+ }
427+ return target, true
428+ }
429+ return protocol.ExactSessionTarget{}, false
430+}
431+
432+// productionBrokerConnector is every frontend's production broker connector. It
433+// performs no I/O at construction: Connect connect-or-spawns the per-user
434+// broker on demand, so a broker that is absent or incompatible is an ordinary
435+// attempt failure under the supervisor's own backoff instead of a fatal startup
436+// error. It never dials a daemon and never selects a transport.
437+//
438+// It is stateless beyond its connect function: the supervisor owns and closes
439+// every service it adopts, so the connector keeps no eager connection and no
440+// close path of its own.
441+type productionBrokerConnector struct {
442+ connect func(context.Context) (ports.BrokerService, error)
443+}
444+
445+func localBrokerOperationRoute(snapshot ports.BrokerSnapshot) client.BrokerOperationRoute {
446+ return client.BrokerOperationRoute{
447+ Epoch: snapshot.Epoch,
448+ Destination: ports.BrokerEndpointFence{Local: true},
449+ }
450+}
451+
452+var _ ports.BrokerConnector = (*productionBrokerConnector)(nil)
453+
454+// newProductionBrokerConnector returns a lazy production connector. The
455+// connect function is a composition seam for tests; production uses the shared
456+// connect-or-spawn.
457+func newProductionBrokerConnector() ports.BrokerConnector {
458+ return &productionBrokerConnector{connect: func(ctx context.Context) (ports.BrokerService, error) {
459+ return connectProductionClientBroker(ctx)
460+ }}
461+}
462+
463+// Connect connect-or-spawns one broker connection for the calling attempt,
464+// including the first one, so every attempt is under the supervisor's retry
465+// cadence.
466+func (c *productionBrokerConnector) Connect(ctx context.Context) (ports.BrokerService, error) {
467+ if c == nil || c.connect == nil {
468+ return nil, errors.New("vev: broker connector is not configured")
469+ }
470+ return c.connect(ctx)
471+}
472+
473+// brokerClientRender adapts the shared presentation to the plain render callback
474+// the composition tests exercise directly.
475+func brokerClientRender(terminal ports.Terminal, picker *client.Picker, onState func(client.State)) func(client.State) {
476+ presentation := &brokerClientPresentation{terminal: terminal, picker: picker, onState: onState}
477+ return presentation.Render
478+}
479+
480+// terminalBrokerSeams are the optional observation callbacks of one terminal
481+// run. Production supplies none: runBrokerClient reports a failure on the
482+// terminal itself through the same writer the picker paints with. The terminal
483+// composition tests substitute every callback to observe the same run's states,
484+// lifecycle notices, and failures.
485+type terminalBrokerSeams struct {
486+ OnState func(client.State)
487+ OnLifecycle func(client.LifecycleNotice)
488+ OnFailure func(error)
489+}
490+
491+// terminalBrokerCallbacks is the composition-root seam producing one terminal
492+// run's callbacks.
493+var terminalBrokerCallbacks = func() terminalBrokerSeams { return terminalBrokerSeams{} }
494+
495+// Composition-root seams for the terminal client. Production supplies the
496+// production connect-or-spawn, the physical terminal, and the detached local
497+// creation; tests substitute them so the terminal composition is exercised
498+// without a production broker process, a real controlling terminal, or a real
499+// daemon.
500+var (
501+ connectProductionClientBroker = connectProductionBroker
502+ terminalForAttach = func() ports.Terminal { return term.New() }
503+ createDetachedTerminalSession = createDetachedLocalSession
504+)
505+
506+// terminalAttachmentEnvironment copies the composition-owned process context an
507+// attachment Hello carries. The supervisor and the broker never inspect the
508+// process environment themselves.
509+func terminalAttachmentEnvironment() client.AttachmentEnvironment {
510+ return client.AttachmentEnvironment{
511+ TermEnv: os.Getenv("TERM"),
512+ Cwd: currentWorkingDirectory(),
513+ TrueColor: terminalcap.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
514+ }
515+}
516+
517+// terminalSessionEnvironment captures one LocalCLI process snapshot for
518+// session creation before NewSupervisor. The supervisor remaps each remote
519+// request to Remote, so the base provenance stays LocalCLI without branching
520+// in the app.
521+func terminalSessionEnvironment() client.SessionEnvironment {
522+ return client.SessionEnvironment{
523+ Provenance: client.SessionEnvironmentLocalCLI,
524+ Env: append([]string(nil), os.Environ()...),
525+ Cwd: currentWorkingDirectory(),
526+ }
527+}
528+
529+func currentWorkingDirectory() string {
530+ cwd, err := os.Getwd()
531+ if err != nil {
532+ return ""
533+ }
534+ return cwd
535+}
A internal/app/broker_client_test.go +1033 -0
@@ -0,0 +1,1033 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+ "os"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/vev/internal/adapters/brokeripc"
17+ "github.com/bnema/vev/internal/adapters/clock"
18+ "github.com/bnema/vev/internal/domain"
19+ "github.com/bnema/vev/internal/ports"
20+ "github.com/bnema/vev/internal/protocol"
21+ "github.com/bnema/vev/internal/protocol/catalogue"
22+ "github.com/bnema/vev/internal/usecase/client"
23+)
24+
25+// Terminal composition tests (Plan 001 P7.4a).
26+//
27+// runAttach is the ordinary terminal entry point. These tests drive it through
28+// the composition-root seams only: connectProductionClientBroker supplies one
29+// scripted broker connection and terminalForAttach supplies a recording
30+// terminal, so the whole client supervisor runs without a production broker
31+// process, a daemon dialer, or a controlling terminal. Every assertion is about
32+// the composition contract: which broker stream the parsed CLI target resolves
33+// to, that a refused target opens nothing, and that an admitted attachment
34+// commits a real session publication to the terminal.
35+
36+const (
37+ terminalCompositionEpoch = ports.BrokerEpoch(41)
38+ terminalCompositionGeneration = domain.RemoteGeneration(3)
39+)
40+
41+func terminalCompositionPolicy() ports.BrokerPolicy {
42+ return ports.BrokerPolicy{
43+ ProtocolVersion: protocol.Version,
44+ CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
45+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
46+ Transport: "unix-mux",
47+ Trust: "same-user",
48+ Launch: "explicit",
49+ Isolation: "per-user",
50+ }
51+}
52+
53+func terminalCompositionRegistration(endpoint string) domain.RemoteRegistration {
54+ var incarnation [16]byte
55+ for i := range incarnation {
56+ incarnation[i] = byte(i + 1)
57+ }
58+ return domain.RemoteRegistration{Endpoint: endpoint, Incarnation: incarnation, Generation: terminalCompositionGeneration}
59+}
60+
61+// terminalCompositionSnapshot publishes the local daemon plus the given remote
62+// endpoints, each carrying exactly the sessions named.
63+func terminalCompositionSnapshot(localSessions []string, remotes map[string][]string) ports.BrokerSnapshot {
64+ policy := terminalCompositionPolicy()
65+ observations := []ports.BrokerDaemonObservation{{
66+ Local: true,
67+ DisplayOrigin: "local",
68+ Policy: policy,
69+ Identity: "local-daemon",
70+ Incarnation: ports.BrokerDaemonIncarnation{9},
71+ ProtocolVersion: protocol.Version,
72+ Availability: domain.RemoteAvailabilityReachable,
73+ LastSuccess: time.Now(),
74+ InventoryKnown: true,
75+ Sessions: terminalCompositionSessions(localSessions),
76+ }}
77+ endpoints := make([]string, 0, len(remotes))
78+ for endpoint := range remotes {
79+ endpoints = append(endpoints, endpoint)
80+ }
81+ sortStrings(endpoints)
82+ for _, endpoint := range endpoints {
83+ observations = append(observations, ports.BrokerDaemonObservation{
84+ Endpoint: endpoint,
85+ DisplayOrigin: endpoint,
86+ Policy: policy,
87+ Registration: terminalCompositionRegistration(endpoint),
88+ Identity: ports.BrokerDaemonIdentity("identity-" + endpoint),
89+ Incarnation: ports.BrokerDaemonIncarnation{7},
90+ ProtocolVersion: protocol.Version,
91+ Availability: domain.RemoteAvailabilityReachable,
92+ LastSuccess: time.Now(),
93+ InventoryKnown: true,
94+ Sessions: terminalCompositionSessions(remotes[endpoint]),
95+ })
96+ }
97+ return ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1, Daemons: observations}
98+}
99+
100+func terminalCompositionSessions(names []string) []catalogue.RemoteCatalogSession {
101+ sessions := make([]catalogue.RemoteCatalogSession, 0, len(names))
102+ for i, name := range names {
103+ var lifecycle domain.SessionLifecycleID
104+ lifecycle[0] = byte(i + 3)
105+ lifecycle[1] = 0x5a
106+ sessions = append(sessions, catalogue.RemoteCatalogSession{
107+ LifecycleID: lifecycle, Name: name, State: catalogue.RemoteCatalogSessionUp,
108+ })
109+ }
110+ return sessions
111+}
112+
113+func sortStrings(values []string) {
114+ for i := 1; i < len(values); i++ {
115+ for j := i; j > 0 && values[j] < values[j-1]; j-- {
116+ values[j], values[j-1] = values[j-1], values[j]
117+ }
118+ }
119+}
120+
121+// terminalCompositionService is the scripted broker service one terminal client
122+// adopts. It records every opened stream request, serves one committed
123+// publication, and answers each admitted attachment with a Welcome and one
124+// initial full session frame so the supervisor reaches its attached
125+// presentation.
126+type terminalCompositionService struct {
127+ mu sync.Mutex
128+ snapshot ports.BrokerSnapshot
129+ closed bool
130+ next ports.BrokerStreamID
131+ opened []ports.BrokerOpenStreamRequest
132+ streams []*terminalCompositionStream
133+ done chan struct{}
134+ changed chan struct{}
135+}
136+
137+func newTerminalCompositionService(snapshot ports.BrokerSnapshot) *terminalCompositionService {
138+ return &terminalCompositionService{
139+ snapshot: snapshot,
140+ done: make(chan struct{}),
141+ changed: make(chan struct{}, 1),
142+ }
143+}
144+
145+func (s *terminalCompositionService) ConnectionID() ports.BrokerConnectionID {
146+ return ports.BrokerConnectionID{4}
147+}
148+
149+func (s *terminalCompositionService) NextStreamID() (ports.BrokerStreamID, error) {
150+ s.mu.Lock()
151+ defer s.mu.Unlock()
152+ if s.closed {
153+ return 0, ports.BrokerAdmissionClosed
154+ }
155+ s.next++
156+ return s.next, nil
157+}
158+
159+func (s *terminalCompositionService) Done() <-chan struct{} { return s.done }
160+func (s *terminalCompositionService) Err() error { return nil }
161+
162+func (s *terminalCompositionService) Snapshot() ports.BrokerSnapshot { return s.snapshot.Clone() }
163+
164+func (s *terminalCompositionService) Subscribe() (ports.BrokerSubscription, error) {
165+ return terminalCompositionSubscription{changed: s.changed}, nil
166+}
167+
168+func (s *terminalCompositionService) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
169+ s.mu.Lock()
170+ if s.closed {
171+ s.mu.Unlock()
172+ return nil, ports.BrokerAdmissionClosed
173+ }
174+ s.opened = append(s.opened, request)
175+ stream := newTerminalCompositionStream(request)
176+ s.streams = append(s.streams, stream)
177+ s.mu.Unlock()
178+ return stream, nil
179+}
180+
181+func (s *terminalCompositionService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {
182+ return nil
183+}
184+
185+func (s *terminalCompositionService) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
186+ return domain.RemoteRegistration{}, nil
187+}
188+
189+func (s *terminalCompositionService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) {
190+ return false, nil
191+}
192+
193+func (s *terminalCompositionService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
194+ return domain.RemoteRegistration{}, nil
195+}
196+
197+func (s *terminalCompositionService) RequestReconcile(string) {}
198+
199+func (s *terminalCompositionService) Close() error {
200+ s.mu.Lock()
201+ defer s.mu.Unlock()
202+ if !s.closed {
203+ s.closed = true
204+ close(s.done)
205+ }
206+ return nil
207+}
208+
209+func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRequest {
210+ s.mu.Lock()
211+ defer s.mu.Unlock()
212+ return append([]ports.BrokerOpenStreamRequest(nil), s.opened...)
213+}
214+
215+// streamHello returns the Hello the supervisor sent on the single admitted
216+// stream, proving the composition carried the CLI intent through to the typed
217+// session handshake.
218+func (s *terminalCompositionService) streamHello(t *testing.T) protocol.Hello {
219+ t.Helper()
220+ s.mu.Lock()
221+ streams := append([]*terminalCompositionStream(nil), s.streams...)
222+ s.mu.Unlock()
223+ require.Len(t, streams, 1)
224+ for _, message := range streams[0].sentMessages() {
225+ if hello, ok := message.(protocol.Hello); ok {
226+ return hello
227+ }
228+ }
229+ t.Fatal("the composed client never sent a session Hello")
230+ return protocol.Hello{}
231+}
232+
233+type terminalCompositionSubscription struct{ changed chan struct{} }
234+
235+func (s terminalCompositionSubscription) Changed() <-chan struct{} { return s.changed }
236+func (s terminalCompositionSubscription) Close() {}
237+
238+// terminalCompositionStream answers one admitted attachment with a Welcome and
239+// one initial full session frame carrying real terminal bytes, so a test can
240+// prove the committed publication reached the terminal.
241+type terminalCompositionStream struct {
242+ request ports.BrokerOpenStreamRequest
243+
244+ mu sync.Mutex
245+ sent []protocol.ClientMessage
246+ queue []protocol.ServerMessage
247+ closed bool
248+ done chan struct{}
249+}
250+
251+func newTerminalCompositionStream(request ports.BrokerOpenStreamRequest) *terminalCompositionStream {
252+ stream := &terminalCompositionStream{request: request, done: make(chan struct{})}
253+ target := terminalCompositionCommittedTarget(request)
254+ stream.queue = append(stream.queue, protocol.Welcome{
255+ SessionID: "sess", SessionName: request.Name,
256+ CommittedIdentity: &protocol.CommittedRouteIdentity{Target: target},
257+ })
258+ stream.queue = append(stream.queue, protocol.Output{
259+ Epoch: 1, Full: true, New: 1,
260+ Size: domain.Size{Cols: 80, Rows: 24},
261+ Context: &protocol.ViewContext{
262+ Publication: 1,
263+ Route: protocol.CommittedRouteIdentity{Target: target},
264+ TabID: domain.TabStableID("tab-1"),
265+ FocusedPaneID: domain.PaneStableID("pane-1"),
266+ },
267+ Data: []byte("terminal-composition-ready\r\n"),
268+ })
269+ return stream
270+}
271+
272+// terminalCompositionCommittedTarget is the exact identity the scripted daemon
273+// commits for one admitted request. An ephemeral creation has no requested name,
274+// so the scripted daemon allocates one exactly as the real daemon does.
275+func terminalCompositionCommittedTarget(request ports.BrokerOpenStreamRequest) protocol.ExactSessionTarget {
276+ if request.Admission == ports.BrokerAdmissionExact {
277+ return request.Target
278+ }
279+ name := request.Name
280+ if name == "" {
281+ name = "1"
282+ }
283+ var lifecycle domain.SessionLifecycleID
284+ lifecycle[0] = 0x11
285+ return protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}
286+}
287+
288+func (s *terminalCompositionStream) SendClient(message protocol.ClientMessage) error {
289+ s.mu.Lock()
290+ defer s.mu.Unlock()
291+ s.sent = append(s.sent, message)
292+ return nil
293+}
294+
295+func (s *terminalCompositionStream) ReceiveServer() (protocol.ServerMessage, error) {
296+ s.mu.Lock()
297+ if len(s.queue) != 0 {
298+ message := s.queue[0]
299+ s.queue = s.queue[1:]
300+ s.mu.Unlock()
301+ return message, nil
302+ }
303+ s.mu.Unlock()
304+ <-s.done
305+ return nil, io.EOF
306+}
307+
308+func (s *terminalCompositionStream) Capabilities() protocol.ConnectionCapabilities {
309+ return protocol.ConnectionCapabilities{}
310+}
311+
312+func (s *terminalCompositionStream) LinkState() ports.LinkState { return ports.LinkStateConnected }
313+func (s *terminalCompositionStream) LinkEvents() <-chan ports.LinkEvent {
314+ return nil
315+}
316+
317+func (s *terminalCompositionStream) Done() <-chan struct{} { return s.done }
318+func (s *terminalCompositionStream) Err() error { return nil }
319+
320+func (s *terminalCompositionStream) Close() error {
321+ s.mu.Lock()
322+ defer s.mu.Unlock()
323+ if !s.closed {
324+ s.closed = true
325+ close(s.done)
326+ }
327+ return nil
328+}
329+
330+func (s *terminalCompositionStream) sentMessages() []protocol.ClientMessage {
331+ s.mu.Lock()
332+ defer s.mu.Unlock()
333+ return append([]protocol.ClientMessage(nil), s.sent...)
334+}
335+
336+// terminalCompositionTerminal is the terminal double: it records written bytes,
337+// flushes, raw-mode entries/restores, and every UI publication so a test can
338+// prove a committed attachment published a real frame.
339+type terminalCompositionTerminal struct {
340+ mu sync.Mutex
341+ out []byte
342+ flushes int
343+ restores int
344+ statuses []ports.UIPresentationStatus
345+ changes chan struct{}
346+ input *terminalCompositionInput
347+}
348+
349+func newTerminalCompositionTerminal() *terminalCompositionTerminal {
350+ return &terminalCompositionTerminal{changes: make(chan struct{}), input: newTerminalCompositionInput()}
351+}
352+
353+// terminalCompositionInput is the supervisor's single terminal reader. It
354+// yields bytes only when a test pushes them and reaches EOF when it closes, so
355+// a client with no pushed input stays live until the test ends the run.
356+type terminalCompositionInput struct {
357+ chunks chan []byte
358+}
359+
360+func newTerminalCompositionInput() *terminalCompositionInput {
361+ return &terminalCompositionInput{chunks: make(chan []byte)}
362+}
363+
364+func (i *terminalCompositionInput) Read(buffer []byte) (int, error) {
365+ chunk, ok := <-i.chunks
366+ if !ok {
367+ return 0, io.EOF
368+ }
369+ return copy(buffer, chunk), nil
370+}
371+
372+func (t *terminalCompositionTerminal) EnterRaw() (func() error, error) {
373+ return func() error {
374+ t.mu.Lock()
375+ t.restores++
376+ t.mu.Unlock()
377+ return nil
378+ }, nil
379+}
380+
381+func (t *terminalCompositionTerminal) Geometry() (domain.Geometry, error) {
382+ return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
383+}
384+
385+func (t *terminalCompositionTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
386+
387+// In blocks until the run ends: the supervisor owns exactly one terminal read
388+// lifetime, and an immediately-empty reader would look like a terminal EOF and
389+// end an otherwise live client.
390+func (t *terminalCompositionTerminal) In() io.Reader { return t.input }
391+
392+func (t *terminalCompositionTerminal) Out() io.Writer { return t }
393+
394+func (t *terminalCompositionTerminal) Write(data []byte) (int, error) {
395+ t.mu.Lock()
396+ defer t.mu.Unlock()
397+ t.out = append(t.out, data...)
398+ return len(data), nil
399+}
400+
401+func (t *terminalCompositionTerminal) Flush() error {
402+ t.mu.Lock()
403+ defer t.mu.Unlock()
404+ t.flushes++
405+ return nil
406+}
407+
408+func (t *terminalCompositionTerminal) BeginOutput(ports.UIContext) {}
409+func (t *terminalCompositionTerminal) EndOutput(bool) {}
410+
411+func (t *terminalCompositionTerminal) PublishContext(context ports.UIContext) error {
412+ t.mu.Lock()
413+ defer t.mu.Unlock()
414+ t.statuses = append(t.statuses, context.Status)
415+ return nil
416+}
417+
418+func (t *terminalCompositionTerminal) written() string {
419+ t.mu.Lock()
420+ defer t.mu.Unlock()
421+ return string(t.out)
422+}
423+
424+func (t *terminalCompositionTerminal) publishes() []ports.UIPresentationStatus {
425+ t.mu.Lock()
426+ defer t.mu.Unlock()
427+ return append([]ports.UIPresentationStatus(nil), t.statuses...)
428+}
429+
430+// startTerminalComposition drives the real runAttach entry point over one
431+// scripted broker service and recording terminal. It substitutes only the two
432+// composition seams (connect-or-spawn and the terminal) plus the callback seam,
433+// so every assertion holds for the production terminal path itself and not for
434+// a parallel test harness.
435+type terminalCompositionRun struct {
436+ service *terminalCompositionService
437+ terminal *terminalCompositionTerminal
438+ states chan client.State
439+ notices chan client.LifecycleNotice
440+ failures chan error
441+ finished chan struct{}
442+ runMu sync.Mutex
443+ runErr error
444+ cancel context.CancelFunc
445+}
446+
447+// wait returns the run's terminal error. It is idempotent: every caller waits on
448+// the same closed channel, so a failure observed by one helper never consumes the
449+// result another helper needs.
450+func (r *terminalCompositionRun) wait(t *testing.T) error {
451+ t.Helper()
452+ select {
453+ case <-r.finished:
454+ case <-time.After(brokerTestWait):
455+ t.Fatal("terminal client did not stop")
456+ }
457+ r.runMu.Lock()
458+ defer r.runMu.Unlock()
459+ return r.runErr
460+}
461+
462+func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot) *terminalCompositionRun {
463+ t.Helper()
464+ // The ordinary terminal path reads the nested-session and remote-transport
465+ // environment; a test drives the broker composition with both unset.
466+ t.Setenv("VEV", "")
467+ t.Setenv(envRemoteTransport, "")
468+ service := newTerminalCompositionService(snapshot)
469+ terminal := newTerminalCompositionTerminal()
470+ previousConnect := connectProductionClientBroker
471+ previousTerminal := terminalForAttach
472+ previousCallbacks := terminalBrokerCallbacks
473+ connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) {
474+ return service, nil
475+ }
476+ terminalForAttach = func() ports.Terminal { return terminal }
477+
478+ run := &terminalCompositionRun{
479+ service: service,
480+ terminal: terminal,
481+ states: make(chan client.State, 64),
482+ notices: make(chan client.LifecycleNotice, 16),
483+ failures: make(chan error, 16),
484+ finished: make(chan struct{}),
485+ }
486+ terminalBrokerCallbacks = func() terminalBrokerSeams {
487+ return terminalBrokerSeams{
488+ OnState: func(state client.State) {
489+ select {
490+ case run.states <- state:
491+ default:
492+ }
493+ },
494+ OnLifecycle: func(notice client.LifecycleNotice) {
495+ select {
496+ case run.notices <- notice:
497+ default:
498+ }
499+ },
500+ OnFailure: func(err error) {
501+ select {
502+ case run.failures <- err:
503+ default:
504+ }
505+ },
506+ }
507+ }
508+ t.Cleanup(func() {
509+ connectProductionClientBroker = previousConnect
510+ terminalForAttach = previousTerminal
511+ terminalBrokerCallbacks = previousCallbacks
512+ })
513+
514+ ctx, cancel := context.WithCancel(context.Background())
515+ run.cancel = cancel
516+ go func() {
517+ err := runAttach(ctx, intent, name, remoteTarget)
518+ run.runMu.Lock()
519+ run.runErr = err
520+ run.runMu.Unlock()
521+ close(run.finished)
522+ }()
523+ t.Cleanup(func() {
524+ cancel()
525+ select {
526+ case <-run.finished:
527+ case <-time.After(brokerTestWait):
528+ t.Error("terminal client did not stop")
529+ }
530+ })
531+ return run
532+}
533+
534+// awaitTerminalCompositionAttachment waits for the supervisor to present an
535+// attached state and returns it.
536+func awaitTerminalCompositionAttachment(t *testing.T, run *terminalCompositionRun) client.State {
537+ t.Helper()
538+ deadline := time.NewTimer(brokerTestWait)
539+ defer deadline.Stop()
540+ for {
541+ select {
542+ case state := <-run.states:
543+ if state.Presentation == client.PresentAttached {
544+ return state
545+ }
546+ case <-run.finished:
547+ t.Fatalf("terminal client ended before attaching: %v", run.wait(t))
548+ case <-deadline.C:
549+ t.Fatal("terminal client never attached")
550+ }
551+ }
552+}
553+
554+// awaitTerminalCompositionNotice waits for one bounded lifecycle notice.
555+func awaitTerminalCompositionNotice(t *testing.T, run *terminalCompositionRun) client.LifecycleNotice {
556+ t.Helper()
557+ select {
558+ case notice := <-run.notices:
559+ return notice
560+ case <-time.After(brokerTestWait):
561+ t.Fatal("terminal client reported no lifecycle notice")
562+ return client.LifecycleNotice{}
563+ }
564+}
565+
566+func TestTerminalNavigationTranslationClosesTheIntent(t *testing.T) {
567+ tests := []struct {
568+ name string
569+ intent uint8
570+ session string
571+ remote string
572+ wantKind client.InitialNavigationKind
573+ wantLocal bool
574+ wantName string
575+ wantResolver bool
576+ wantError bool
577+ }{
578+ {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, wantKind: client.InitialNavigationCreateEphemeral, wantLocal: true},
579+ {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", wantKind: client.InitialNavigationCreateNamed, wantLocal: true, wantName: "scratch"},
580+ {name: "attach resolves a local exact target", intent: protocol.IntentAttach, session: "work", wantResolver: true},
581+ {name: "remote attach resolves an exact remote target", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true},
582+ {name: "remote new resolves a named remote creation", intent: protocol.IntentNew, session: "work", remote: "user@example.test", wantResolver: true},
583+ {name: "remote ephemeral attach resolves a remote creation", intent: protocol.IntentEphemeral, remote: "user@example.test", wantResolver: true},
584+ {name: "invalid local name is refused", intent: protocol.IntentNew, session: "bad name", wantError: true},
585+ {name: "invalid remote target is refused", intent: protocol.IntentAttach, session: "work", remote: "bad target", wantError: true},
586+ }
587+ for _, tc := range tests {
588+ t.Run(tc.name, func(t *testing.T) {
589+ navigation, resolver, err := terminalBrokerNavigation(tc.intent, tc.session, tc.remote)
590+ if tc.wantError {
591+ require.Error(t, err)
592+ require.Nil(t, resolver)
593+ require.Equal(t, client.InitialNavigation{}, navigation)
594+ return
595+ }
596+ require.NoError(t, err)
597+ if tc.wantResolver {
598+ require.NotNil(t, resolver)
599+ require.Equal(t, client.InitialNavigation{}, navigation)
600+ return
601+ }
602+ require.Nil(t, resolver)
603+ require.Equal(t, tc.wantKind, navigation.Kind)
604+ require.Equal(t, tc.wantLocal, navigation.Destination.Local)
605+ require.Equal(t, tc.wantName, navigation.Name)
606+ require.NoError(t, navigation.Validate())
607+ })
608+ }
609+}
610+
611+// TestTerminalCompositionOpensTheExactBrokerStream pins the ordinary terminal
612+// entry point to the broker: each parsed CLI target resolves to exactly one
613+// admission on one broker logical stream, and the composition dials no daemon.
614+func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) {
615+ localSnapshot := func() ports.BrokerSnapshot {
616+ return terminalCompositionSnapshot([]string{"work"}, nil)
617+ }
618+ remoteSnapshot := func() ports.BrokerSnapshot {
619+ return terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})
620+ }
621+ tests := []struct {
622+ name string
623+ intent uint8
624+ session string
625+ remote string
626+ snapshot ports.BrokerSnapshot
627+ wantAdmission ports.BrokerStreamAdmission
628+ wantLocal bool
629+ wantName string
630+ wantHello uint8
631+ }{
632+ {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateEphemeral, wantLocal: true, wantHello: protocol.IntentEphemeral},
633+ {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateNamed, wantLocal: true, wantName: "scratch", wantHello: protocol.IntentNew},
634+ {name: "attach resolves the local session lifecycle", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantLocal: true, wantHello: protocol.IntentAttach},
635+ {name: "remote attach resolves the remote session lifecycle", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", snapshot: remoteSnapshot(), wantAdmission: ports.BrokerAdmissionExact, wantHello: protocol.IntentAttach},
636+ }
637+ for _, tc := range tests {
638+ t.Run(tc.name, func(t *testing.T) {
639+ run := startTerminalComposition(t, tc.intent, tc.session, tc.remote, tc.snapshot)
640+ awaitTerminalCompositionAttachment(t, run)
641+ opened := run.service.openedRequests()
642+ require.Len(t, opened, 1, "one CLI target opens exactly one broker stream")
643+ request := opened[0]
644+ require.NoError(t, request.Validate())
645+ require.Equal(t, terminalCompositionEpoch, request.Epoch)
646+ require.Equal(t, ports.BrokerStreamAttachment, request.Purpose)
647+ require.Equal(t, tc.wantAdmission, request.Admission)
648+ require.Equal(t, tc.wantLocal, request.Local)
649+ require.Equal(t, tc.wantName, request.Name)
650+ require.Equal(t, ports.BrokerConnectionID{4}, request.Connection)
651+ require.NotZero(t, request.Stream)
652+ require.NotEmpty(t, request.Policy.Transport)
653+ require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)
654+ if !tc.wantLocal {
655+ require.Equal(t, "user@example.test", request.Endpoint)
656+ require.Equal(t, terminalCompositionRegistration("user@example.test"), request.Registration)
657+ }
658+ if tc.wantAdmission == ports.BrokerAdmissionExact {
659+ require.NoError(t, request.Target.Validate())
660+ require.Equal(t, tc.session, request.Target.SessionName)
661+ }
662+ // The same intent reaches the typed session handshake, so the
663+ // broker admission and the daemon Hello can never disagree.
664+ hello := run.service.streamHello(t)
665+ require.Equal(t, tc.wantHello, hello.Intent)
666+ require.Equal(t, tc.session, hello.Name)
667+ require.Equal(t, tc.remote != "", hello.Remote)
668+ if tc.wantAdmission == ports.BrokerAdmissionExact {
669+ require.NotNil(t, hello.ExactTarget)
670+ require.Equal(t, request.Target, *hello.ExactTarget)
671+ }
672+ })
673+ }
674+}
675+
676+// TestTerminalCompositionPublishesRealSessionOutput proves an admitted
677+// attachment is a committed publication: the daemon's initial full frame reached
678+// the terminal through a committed UI output transaction, and the supervisor
679+// presented attached only after that commit.
680+func TestTerminalCompositionPublishesRealSessionOutput(t *testing.T) {
681+ run := startTerminalComposition(t, protocol.IntentEphemeral, "", "", terminalCompositionSnapshot([]string{"work"}, nil))
682+ awaitTerminalCompositionAttachment(t, run)
683+ require.Contains(t, run.terminal.written(), "terminal-composition-ready")
684+ // The initial publication is committed before the attached marker, so its
685+ // recorded boundary status is the pre-attachment Connecting presentation.
686+ require.Contains(t, run.terminal.publishes(), ports.UIStatusConnecting)
687+ require.Contains(t, run.terminal.publishes(), ports.UIStatusAttached)
688+ _, _, restores := run.terminalCounts()
689+ require.Zero(t, restores)
690+}
691+
692+func (r *terminalCompositionRun) terminalCounts() (int, int, int) {
693+ r.terminal.mu.Lock()
694+ defer r.terminal.mu.Unlock()
695+ return len(r.terminal.out), r.terminal.flushes, r.terminal.restores
696+}
697+
698+// TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker pins the
699+// failure contract: a target the committed catalogue does not carry is a local
700+// refusal. No stream is opened, the supervisor reports a bounded
701+// selection-unavailable notice, and the process stays in the picker instead of
702+// attaching a fallback or creating implicitly.
703+func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testing.T) {
704+ tests := []struct {
705+ name string
706+ intent uint8
707+ session string
708+ remote string
709+ snapshot ports.BrokerSnapshot
710+ }{
711+ {name: "missing local session", intent: protocol.IntentAttach, session: "gone", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
712+ {name: "local daemon absent", intent: protocol.IntentEphemeral, snapshot: ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1}},
713+ {name: "unconfigured remote host", intent: protocol.IntentAttach, session: "work", remote: "user@elsewhere.test", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
714+ {name: "missing remote session", intent: protocol.IntentAttach, session: "gone", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})},
715+ }
716+ for _, tc := range tests {
717+ t.Run(tc.name, func(t *testing.T) {
718+ run := startTerminalComposition(t, tc.intent, tc.session, tc.remote, tc.snapshot)
719+ require.Equal(t, client.LifecycleNoticeSelectionUnavailable, awaitTerminalCompositionNotice(t, run).Kind)
720+ // A local refusal is surfaced once, with the bounded text the supervisor
721+ // classifies; the resolver's own sentinel is deliberately not propagated
722+ // past that taxonomy, so only the presence and the classification are
723+ // asserted.
724+ select {
725+ case failure := <-run.failures:
726+ require.Error(t, failure)
727+ case <-time.After(brokerTestWait):
728+ t.Fatal("the refused target reported no failure")
729+ }
730+ require.Empty(t, run.service.openedRequests(), "a refused target never opens a stream")
731+ for {
732+ select {
733+ case state := <-run.states:
734+ require.Equal(t, client.PresentPicker, state.Presentation, "a refusal keeps the picker presentation")
735+ case <-time.After(100 * time.Millisecond):
736+ return
737+ }
738+ }
739+ })
740+ }
741+}
742+
743+// TestTerminalCompositionClosesRawModeOnTerminalEnd pins that the shared
744+// composition restores the terminal exactly once when the run ends, however the
745+// attachment settled.
746+func TestTerminalCompositionClosesRawModeOnTerminalEnd(t *testing.T) {
747+ run := startTerminalComposition(t, protocol.IntentEphemeral, "", "", terminalCompositionSnapshot([]string{"work"}, nil))
748+ awaitTerminalCompositionAttachment(t, run)
749+ run.cancel()
750+ require.NoError(t, ignoreContextCancellation(run.wait(t)))
751+ _, _, restores := run.terminalCounts()
752+ require.Equal(t, 1, restores, "raw mode is restored exactly once")
753+}
754+
755+// TestTerminalCompositionCapturesSessionEnvironmentDefensively proves the test
756+// terminal composition keeps its process snapshot defensive: it captures
757+// env/Cwd, mutates the process seam after construction, and the supervisor
758+// still creates without failure.
759+func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T) {
760+ t.Setenv("VEV_TEST_SESSION_ENV", "composition-value")
761+ snapshot := terminalSessionEnvironment()
762+ require.Equal(t, client.SessionEnvironmentLocalCLI, snapshot.Provenance)
763+ require.NoError(t, snapshot.Validate())
764+ require.Contains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=composition-value")
765+ // Mutate the process seam after capture: the snapshot must keep the
766+ // captured value rather than aliasing live process state.
767+ require.NoError(t, os.Setenv("VEV_TEST_SESSION_ENV", "mutated-after-capture"))
768+ require.Contains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=composition-value")
769+ require.NotContains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=mutated-after-capture")
770+ clone := snapshot.Clone()
771+ // Mutate the clone's slice after capture: the supervisor creation must
772+ // not fail and the source slice stays intact, so the snapshot is a copy
773+ // rather than a live alias of the process environment.
774+ for i := range clone.Env {
775+ clone.Env[i] = "MUTATED=x"
776+ }
777+ require.NotEqual(t, snapshot.Env, clone.Env)
778+ servicesnapshot := terminalCompositionSnapshot([]string{"work"}, nil)
779+ service := newTerminalCompositionService(servicesnapshot)
780+ terminal := newTerminalCompositionTerminal()
781+ navigation := localEphemeralNavigation()
782+ _, err := client.NewSupervisor(client.SupervisorConfig{
783+ Connector: scriptedBrokerConnector{service: service},
784+ Terminal: terminal,
785+ Clock: clock.New(),
786+ // The picker is omitted: creation alone proves the snapshot does not
787+ // fail supervisor validation.
788+ InitialNavigation: navigation,
789+ SessionEnvironment: snapshot,
790+ })
791+ require.NoError(t, err)
792+}
793+
794+// TestTerminalCompositionReportsBrokerFailureInThePicker pins that a broker the
795+// composition cannot connect to (or start) is no longer a fatal startup error:
796+// the run enters raw mode, stays in the picker, and reports the typed failure
797+// without opening any stream. It remains live until the terminal ends.
798+func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) {
799+ t.Setenv("VEV", "")
800+ t.Setenv(envRemoteTransport, "")
801+ startupErr := errors.New("broker not reachable")
802+ previousConnect := connectProductionClientBroker
803+ terminal := newTerminalCompositionTerminal()
804+ previousTerminal := terminalForAttach
805+ connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) { return nil, startupErr }
806+ terminalForAttach = func() ports.Terminal { return terminal }
807+ t.Cleanup(func() {
808+ connectProductionClientBroker = previousConnect
809+ terminalForAttach = previousTerminal
810+ })
811+
812+ ctx, cancel := context.WithCancel(context.Background())
813+ done := make(chan error, 1)
814+ go func() { done <- runAttach(ctx, protocol.IntentEphemeral, "", "") }()
815+
816+ // A broker failure is surfaced, not fatal, and no stream is opened.
817+ require.Eventually(t, func() bool {
818+ for _, status := range terminal.publishes() {
819+ if status == ports.UIStatusConnecting || status == ports.UIStatusAttached {
820+ return false
821+ }
822+ }
823+ return true
824+ }, 200*time.Millisecond, 10*time.Millisecond)
825+ select {
826+ case err := <-done:
827+ t.Fatalf("the composition ended on a broker failure: %v", err)
828+ case <-time.After(200 * time.Millisecond):
829+ }
830+ _, _, restores := terminalCounts(terminal)
831+ require.Zero(t, restores, "the run is still live, so raw mode is not yet restored")
832+
833+ cancel()
834+ require.NoError(t, ignoreContextCancellation(<-done))
835+ _, _, restores = terminalCounts(terminal)
836+ require.Equal(t, 1, restores, "the cancelled run restores raw mode exactly once")
837+}
838+
839+// TestRunAttachNestedSessionBehavior pins the nested-session product contract of
840+// the terminal path: an explicit `new <name>` inside an existing session still
841+// creates the session without attaching, and every other intent is refused
842+// without reaching the broker.
843+func TestRunAttachNestedSessionBehavior(t *testing.T) {
844+ t.Setenv("VEV", "outer")
845+ t.Setenv(envRemoteTransport, "")
846+ connectCalls := 0
847+ previousConnect := connectProductionClientBroker
848+ previousCreate := createDetachedTerminalSession
849+ var createdName string
850+ connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) {
851+ connectCalls++
852+ return nil, errors.New("the broker must not be reached")
853+ }
854+ createDetachedTerminalSession = func(_ context.Context, name string) error {
855+ createdName = name
856+ return nil
857+ }
858+ t.Cleanup(func() {
859+ connectProductionClientBroker = previousConnect
860+ createDetachedTerminalSession = previousCreate
861+ })
862+
863+ require.NoError(t, runAttach(context.Background(), protocol.IntentNew, "scratch", ""))
864+ require.Equal(t, "scratch", createdName)
865+
866+ for _, intent := range []uint8{protocol.IntentEphemeral, protocol.IntentAttach} {
867+ err := runAttach(context.Background(), intent, "scratch", "")
868+ require.ErrorContains(t, err, "sessions should be nested with care")
869+ }
870+ require.Zero(t, connectCalls, "a nested invocation never connects to the broker")
871+}
872+
873+func terminalCounts(terminal *terminalCompositionTerminal) (int, int, int) {
874+ terminal.mu.Lock()
875+ defer terminal.mu.Unlock()
876+ return len(terminal.out), terminal.flushes, terminal.restores
877+}
878+
879+// TestTerminalCompositionUsesTheBrokerConnector pins the production terminal
880+// path source: runAttach composes the shared broker client over the process'
881+// production connector and keeps no direct-dialer attach composition, attach
882+// preflight, or client host registry. The old pre-cutover symbols are removed
883+// outright, so this fails if any is reintroduced as a bypass.
884+func TestTerminalCompositionUsesTheBrokerConnector(t *testing.T) {
885+ sources := loadAppSources(t, false)
886+ body := sources["run.go"]
887+ require.Contains(t, body, "runBrokerClient(ctx, brokerClientConfig{")
888+ require.Contains(t, body, "newProductionBrokerConnector()")
889+ for _, forbidden := range []string{
890+ "runAttachWithDeps", "runAttachDeps", "localDaemonDialer", "dialOnlyLocalDialer",
891+ "defaultLocalDialer", "resolveMissingSessionAttach", "newClientHostRegistry",
892+ "listSessionsWithDialer", "clientEndpointFactory",
893+ } {
894+ require.NotContains(t, body, forbidden, "the terminal composition must not keep %q", forbidden)
895+ }
896+}
897+
898+// TestTerminalExactAttachAcceptedByRealDaemon is the end-to-end regression for
899+// the exact-attach wire shape: against the real broker and a real daemon, the
900+// Hello the client supervisor builds for an exact admission (Intent=attach,
901+// Name equal to the exact target's session name, ExactTarget set) is accepted
902+// and answered with a committed Welcome. It fails if the strict Hello contract
903+// and the worker disagree about the name.
904+func TestTerminalExactAttachAcceptedByRealDaemon(t *testing.T) {
905+ fixture := startOfflineClientFixture(t)
906+ ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
907+ defer cancel()
908+ service, err := brokeripc.NewConnector(fixture.socket, brokeripc.Config{}).Connect(ctx)
909+ require.NoError(t, err)
910+ defer service.Close()
911+ require.NotZero(t, awaitTerminalCompositionEpoch(t, service))
912+
913+ createID, err := service.NextStreamID()
914+ require.NoError(t, err)
915+ create, err := service.OpenStream(ctx, ports.BrokerOpenStreamRequest{
916+ Purpose: ports.BrokerStreamAttachment, Stream: createID, Local: true, Policy: fixture.policy,
917+ Admission: ports.BrokerAdmissionCreateNamed, Name: "attach-exact", StartMode: ports.BrokerDaemonStartIfNeeded,
918+ })
919+ require.NoError(t, err)
920+ require.NoError(t, create.SendClient(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "attach-exact", Size: domain.Size{Cols: 80, Rows: 24}}))
921+ target := receiveOfflineWelcome(t, create).CommittedIdentity.Target
922+ require.NoError(t, create.Close())
923+
924+ exactID, err := service.NextStreamID()
925+ require.NoError(t, err)
926+ exact, err := service.OpenStream(ctx, ports.BrokerOpenStreamRequest{
927+ Purpose: ports.BrokerStreamAttachment, Stream: exactID, Local: true, Policy: fixture.policy,
928+ Admission: ports.BrokerAdmissionExact, Target: target, StartMode: ports.BrokerDaemonStartIfNeeded,
929+ })
930+ require.NoError(t, err)
931+ defer exact.Close()
932+
933+ // The exact Hello shape the client supervisor's worker builds for this
934+ // request; it must satisfy the protocol and be accepted by the daemon.
935+ hello := protocol.Hello{
936+ Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName,
937+ ExactTarget: &target, Size: domain.Size{Cols: 80, Rows: 24},
938+ }
939+ require.NoError(t, protocol.ValidateHello(hello))
940+ require.NoError(t, exact.SendClient(hello))
941+ require.Equal(t, "attach-exact", receiveOfflineWelcome(t, exact).SessionName)
942+}
943+
944+// TestTerminalCompositionExactAttachReportsMissingLocalInventory documents the
945+// composition's behavior when the committed broker publication carries no local
946+// session inventory: an exact attach is refused locally as a bounded selection
947+// notice, opens no stream, and never invents a creation or a same-name
948+// fallback. It pins the honest refusal so a future broker-side local catalogue
949+// is the only thing that can make this navigation succeed.
950+func TestTerminalCompositionExactAttachReportsMissingLocalInventory(t *testing.T) {
951+ fixture := startOfflineClientFixture(t)
952+ ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
953+ defer cancel()
954+
955+ terminal := newTerminalCompositionTerminal()
956+ notices := make(chan client.LifecycleNotice, 16)
957+ failures := make(chan error, 16)
958+ var lifecycleID domain.SessionLifecycleID
959+ lifecycleID[0], lifecycleID[1] = 3, 0x5a
960+ navigation := client.InitialNavigation{
961+ Kind: client.InitialNavigationAttachExact,
962+ Epoch: terminalCompositionEpoch,
963+ Destination: ports.BrokerEndpointFence{Local: true},
964+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycleID, SessionName: "work"},
965+ }
966+ require.NoError(t, navigation.Validate())
967+
968+ done := make(chan error, 1)
969+ go func() {
970+ done <- runBrokerClient(ctx, brokerClientConfig{
971+ Connector: brokeripc.NewConnector(fixture.socket, brokeripc.Config{}),
972+ Terminal: terminal,
973+ InitialNavigation: navigation,
974+ SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI},
975+ OnFailure: func(err error) {
976+ select {
977+ case failures <- err:
978+ default:
979+ }
980+ },
981+ OnLifecycle: func(notice client.LifecycleNotice) {
982+ select {
983+ case notices <- notice:
984+ default:
985+ }
986+ },
987+ })
988+ }()
989+
990+ select {
991+ case notice := <-notices:
992+ require.Equal(t, client.LifecycleNoticeSelectionUnavailable, notice.Kind)
993+ case <-time.After(brokerTestWait):
994+ t.Fatal("the exact attach reported no lifecycle notice")
995+ }
996+ select {
997+ case failure := <-failures:
998+ require.Error(t, failure)
999+ case <-time.After(brokerTestWait):
1000+ t.Fatal("the exact attach reported no failure")
1001+ }
1002+ // The refused navigation never writes a session frame and never attaches:
1003+ // only the Picker presentation may be published, never a Connecting or
1004+ // Attached session presentation.
1005+ for _, status := range terminal.publishes() {
1006+ require.Equal(t, ports.UIStatusPicker, status, "a refused navigation publishes only Picker")
1007+ }
1008+ cancel()
1009+ require.NoError(t, ignoreContextCancellation(<-done))
1010+}
1011+
1012+// awaitTerminalCompositionEpoch waits for one connection's first committed
1013+// publication and returns its epoch.
1014+func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) ports.BrokerEpoch {
1015+ t.Helper()
1016+ sub, err := service.Subscribe()
1017+ require.NoError(t, err)
1018+ defer sub.Close()
1019+ deadline := time.NewTimer(brokerTestWait)
1020+ defer deadline.Stop()
1021+ for {
1022+ if snapshot := service.Snapshot(); snapshot.Epoch != 0 {
1023+ return snapshot.Epoch
1024+ }
1025+ select {
1026+ case <-sub.Changed():
1027+ case <-service.Done():
1028+ t.Fatalf("broker connection ended before publishing a snapshot: %v", service.Err())
1029+ case <-deadline.C:
1030+ t.Fatal("broker never published a snapshot")
1031+ }
1032+ }
1033+}
A internal/app/broker_daemon_start.go +360 -0
@@ -0,0 +1,360 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "syscall"
10+
11+ "github.com/bnema/vev/internal/adapters/brokerconfig"
12+ "github.com/bnema/vev/internal/adapters/daemonmux"
13+ "github.com/bnema/vev/internal/adapters/ipc"
14+ "github.com/bnema/vev/internal/ports"
15+)
16+
17+// Broker-owned daemon start (Plan 001 P7, UI-driver slice 1).
18+//
19+// The broker is the only component that decides whether a target daemon may be
20+// started. Its resolved dial target already carries the closed start
21+// authorization (ports.BrokerDaemonStartMode) together with the policy and the
22+// endpoint fence, and this file is the single place where that authorization
23+// reaches transport mechanics:
24+//
25+// - BrokerDaemonExistingOnly dials the private daemonmux carriage and nothing
26+// else. It never probes the lifecycle lock, never takes the spawn lock,
27+// never launches a process, and never dials a session: an existing-only
28+// acquisition is exactly one carriage dial.
29+// - BrokerDaemonStartIfNeeded dials the same carriage first. A successful dial
30+// is the whole result — an already running daemon is never restarted. Only
31+// when the dial proves the daemon is absent does the start proceed, and only
32+// when the resolved policy explicitly authorizes launching. The start itself
33+// is the existing election in spawn.go: lifecycle availability, one elected
34+// spawner under the shared spawn lock, then a bounded redial of the same
35+// carriage under the same budget.
36+//
37+// The result handed to the pool is always daemonmux: no session transport is
38+// ever produced here, so no caller can fall back to the direct session dial the
39+// broker replaced. Readiness is proven by the carriage the pool then
40+// authenticates with the daemonmux physical preamble, never by a session dial,
41+// a session control stream, or a Hello.
42+//
43+// Refusals are explicit and typed. A daemon that is absent may only be started
44+// under an authorizing policy; a carriage that exists but refuses the peer, the
45+// path, or the credentials is never "fixed" by spawning a second daemon, and a
46+// start that leaves the carriage still undialable is reported rather than
47+// retried with a session dial. Identity, incarnation, policy, and version are
48+// verified by the daemonmux preamble after this function returns, so those
49+// failures can never trigger a spawn either.
50+
51+// brokerLaunchAuthority is the broker-owned policy launch token that explicitly
52+// authorizes starting a stopped target daemon. The ports policy carries the
53+// token opaquely — it never interprets it — so the component that owns "may
54+// this daemon be started?" is the composition. The broker's own production local
55+// daemon authority names this token; an operator-provisioned registration that
56+// names something else, and every sandbox-private token, fails closed and never
57+// spawns.
58+const brokerLaunchAuthority = "explicit"
59+
60+// brokerDaemonStarter owns the three facts a daemon start needs: the private
61+// daemonmux carriage dial, the detached daemon launcher, and the exact carriage
62+// the launched daemon will publish. All three are seams so tests exercise the
63+// real election, policy, and refusal rules without binding a socket or
64+// re-executing a daemon.
65+type brokerDaemonStarter struct {
66+ dial func(ctx context.Context, carriage string) (daemonmux.RawFramedTransport, error)
67+ spawn spawnFunc
68+ backoff backoffConfig
69+ // published returns the private daemonmux carriage the spawned daemon binds.
70+ // Nil means the production endpoint of this process.
71+ published func() string
72+}
73+
74+// localDaemonStarter is the production starter: the real private Unix daemonmux
75+// carriage, the real double-fork daemon launcher, and the exact endpoint that
76+// launcher publishes. It is the broker process' starter and a remote mux
77+// helper's starter alike, because both start the daemon published on their own
78+// machine by re-executing their own binary. A test replaces it with scripted
79+// mechanics and restores it immediately; it is package state for the same reason
80+// defaultBackoff is, and no test mutates it in parallel.
81+var localDaemonStarter = brokerDaemonStarter{dial: dialLocalDaemonCarriage, spawn: realSpawn}
82+
83+// effectiveBackoff returns the starter's explicit backoff, or the production
84+// default read at call time, so a test that narrows defaultBackoff still governs
85+// the daemon start.
86+func (s brokerDaemonStarter) effectiveBackoff() backoffConfig {
87+ if s.backoff.total > 0 {
88+ return s.backoff
89+ }
90+ return defaultBackoff
91+}
92+
93+// effectivePublished returns the private daemonmux carriage the spawned daemon
94+// binds: the starter's explicit endpoint, or this process' own production
95+// daemon endpoint. The daemon launcher re-executes this binary, which always
96+// binds daemonmux.SocketPath(ipc.SocketDir()), so that is the only carriage a
97+// start can ever produce.
98+func (s brokerDaemonStarter) effectivePublished() string {
99+ if s.published != nil {
100+ return s.published()
101+ }
102+ return daemonmux.SocketPath(ipc.SocketDir())
103+}
104+
105+// dialLocalDaemonCarriage dials exactly the private daemonmux carriage at
106+// carriage, verifying that the connected peer is the current effective user. It
107+// is deliberately the mux carriage only: the daemon's public session socket is
108+// never dialed here.
109+func dialLocalDaemonCarriage(ctx context.Context, carriage string) (daemonmux.RawFramedTransport, error) {
110+ return ipc.DialMuxContext(ctx, carriage)
111+}
112+
113+// dialBrokerLocalDaemon dials the daemon published on this machine for one
114+// resolved local target. carriage is the private daemonmux carriage of the
115+// broker-owned local route: that route is its only source, and this function
116+// never derives, guesses, or accepts a carriage of its own.
117+//
118+// A registered endpoint is refused even when it is provisioned on a local Unix
119+// carriage: only the broker-owned local authority names the daemon this
120+// process can start, so a configured host can never be spawned by mistake.
121+func dialBrokerLocalDaemon(ctx context.Context, carriage string, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
122+ if !target.Fence.Local {
123+ return nil, localDaemonStartRefused("the target is not the broker-owned local daemon")
124+ }
125+ return dialBrokerDaemonCarriage(ctx, carriage, target)
126+}
127+
128+// dialBrokerDaemonCarriage is the shared carriage dial and start. The target's
129+// start authorization decides the whole behavior. ExistingOnly performs exactly
130+// one carriage dial. StartIfNeeded performs the same dial first, returns the
131+// daemon it found, and only starts a daemon when the dial proves absence and the
132+// resolved policy authorizes launching; otherwise it reports the refusal without
133+// touching any process.
134+//
135+// A start is only ever possible for the one daemon this process can actually
136+// produce: the launcher re-executes this binary, which binds its private
137+// daemonmux carriage at daemonmux.SocketPath(ipc.SocketDir()) and holds its
138+// lifecycle and spawn locks in that same runtime directory. A carriage that is
139+// anything else is dialed and, if absent, reported — never replaced by a daemon
140+// this process did not start.
141+func dialBrokerDaemonCarriage(ctx context.Context, carriage string, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
142+ if err := target.Validate(); err != nil {
143+ return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
144+ }
145+ if err := validateDaemonCarriage(carriage); err != nil {
146+ return nil, err
147+ }
148+ starter := localDaemonStarter
149+ if starter.dial == nil || starter.spawn == nil {
150+ return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("vev: daemon starter is incomplete")}
151+ }
152+ raw, err := starter.dial(ctx, carriage)
153+ if err == nil {
154+ return raw, nil
155+ }
156+ if target.StartMode == ports.BrokerDaemonExistingOnly {
157+ // An existing-only acquisition is one dial: no lifecycle probe, no spawn
158+ // lock, no process, and no fallback of any kind.
159+ return nil, err
160+ }
161+ // A refusal that spawning could never repair is reported unchanged: a
162+ // cancelled attempt, a foreign path, a rejected peer, or a permission
163+ // failure is not daemon absence.
164+ if !daemonCarriageAbsence(err) {
165+ return nil, err
166+ }
167+ if err := validateDaemonCarriageStart(carriage, starter); err != nil {
168+ return nil, err
169+ }
170+ if err := daemonStartRefusal(target); err != nil {
171+ return nil, err
172+ }
173+ published := starter.effectivePublished()
174+ return ensureMuxDaemon(ctx, filepath.Dir(published), carriage, starter.dial, starter.spawn, starter.effectiveBackoff())
175+}
176+
177+// validateDaemonCarriage refuses a carriage that cannot be a provisioned private
178+// daemonmux endpoint: it must be an absolute, cleaned path. The daemon launcher
179+// re-executes this binary to start one specific daemon, so a relative, unclean,
180+// or empty path can never trigger a spawn.
181+func validateDaemonCarriage(carriage string) error {
182+ if carriage == "" {
183+ return ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: errors.New("vev: daemon carriage path is empty")}
184+ }
185+ if !filepath.IsAbs(carriage) || filepath.Clean(carriage) != carriage {
186+ return ports.BrokerError{
187+ Code: ports.BrokerErrorIncompatible,
188+ Cause: fmt.Errorf("vev: daemon carriage %q is not an absolute cleaned path", carriage),
189+ }
190+ }
191+ return nil
192+}
193+
194+// validateDaemonCarriageStart refuses starting a daemon for any carriage other
195+// than the one the launched daemon will actually publish. The launcher can only
196+// ever produce the daemon bound to that exact private endpoint, so starting one
197+// for a foreign carriage would spawn a process the caller could never reach.
198+func validateDaemonCarriageStart(carriage string, starter brokerDaemonStarter) error {
199+ if want := starter.effectivePublished(); carriage != want {
200+ return ports.BrokerError{
201+ Code: ports.BrokerErrorIncompatible,
202+ Cause: fmt.Errorf("vev: refusing to start a daemon for carriage %q: this process publishes %q", carriage, want),
203+ }
204+ }
205+ return nil
206+}
207+
208+// daemonStartRefusal refuses a start-if-needed acquisition that must never reach
209+// the spawn mechanics. The condition is checked before the election, so a
210+// refused start creates no lock and launches no process.
211+//
212+// The mode is an authorization, never an obligation, and it can only narrow what
213+// the resolved policy already permits. A policy that does not name the broker's
214+// launch authority therefore refuses the start outright, which is exactly how a
215+// provisioned configuration that forbids launching stays authoritative.
216+func daemonStartRefusal(target ports.BrokerDialTarget) error {
217+ if target.StartMode != ports.BrokerDaemonStartIfNeeded {
218+ return localDaemonStartRefused("the acquisition does not authorize starting a daemon")
219+ }
220+ if target.Policy.Launch != brokerLaunchAuthority {
221+ return localDaemonStartRefused("the resolved policy does not authorize launching")
222+ }
223+ return nil
224+}
225+
226+// localDaemonStartRefused reports a refusal to start a daemon as a typed broker
227+// policy conflict. The reason is local diagnostic authority; it is never a
228+// message the daemon produced.
229+func localDaemonStartRefused(reason string) error {
230+ return ports.BrokerError{
231+ Code: ports.BrokerErrorConflictingPolicy,
232+ Cause: fmt.Errorf("vev: refusing to start the daemon: %s", reason),
233+ }
234+}
235+
236+// daemonCarriageAbsence reports whether a failed carriage dial means the daemon
237+// is simply not there, so starting one is the only remaining option. It is
238+// deliberately fail-closed: a cancelled attempt, an invalid or foreign path, a
239+// rejected peer, a permission failure, and every unclassified error report
240+// false, so only proven absence can authorize a spawn.
241+func daemonCarriageAbsence(err error) bool {
242+ switch {
243+ case err == nil:
244+ return false
245+ case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
246+ return false
247+ case errors.Is(err, ipc.ErrMuxPath), errors.Is(err, ipc.ErrMuxForeignPath),
248+ errors.Is(err, ipc.ErrMuxPeerRejected), errors.Is(err, ipc.ErrMuxUnsupported):
249+ return false
250+ case errors.Is(err, os.ErrPermission):
251+ return false
252+ case errors.Is(err, os.ErrNotExist), errors.Is(err, syscall.ENOENT),
253+ errors.Is(err, syscall.ECONNREFUSED), errors.Is(err, syscall.EWOULDBLOCK):
254+ return true
255+ default:
256+ return false
257+ }
258+}
259+
260+// brokerDaemonStartArgvFlag renders the closed start authorization as the exact
261+// helper argument value. An unknown mode is refused, never defaulted: a helper
262+// that cannot read its authorization must not run with a permissive one.
263+func brokerDaemonStartArgvFlag(mode ports.BrokerDaemonStartMode) (string, error) {
264+ switch mode {
265+ case ports.BrokerDaemonExistingOnly:
266+ return "existing-only", nil
267+ case ports.BrokerDaemonStartIfNeeded:
268+ return "if-needed", nil
269+ default:
270+ return "", fmt.Errorf("vev: invalid broker daemon start mode %s", mode)
271+ }
272+}
273+
274+// parseBrokerDaemonStartArgvFlag is the strict inverse of
275+// brokerDaemonStartArgvFlag. An unknown, empty, or differently spelled value is
276+// refused; a helper never maps an unrecognized word onto a start authorization.
277+func parseBrokerDaemonStartArgvFlag(value string) (ports.BrokerDaemonStartMode, error) {
278+ switch value {
279+ case "existing-only":
280+ return ports.BrokerDaemonExistingOnly, nil
281+ case "if-needed":
282+ return ports.BrokerDaemonStartIfNeeded, nil
283+ default:
284+ return 0, fmt.Errorf("vev: unknown daemon start mode %q (want existing-only or if-needed)", value)
285+ }
286+}
287+
288+// helperCarriageTarget resolves the dial target for the private daemonmux
289+// carriage a remote-side mux helper bridges to, strictly from that helper's own
290+// broker-owned configuration. A helper owns no request and no client authority:
291+// the target is the one provisioned entry whose route is exactly the carriage
292+// being bridged, so the helper can only ever narrow what its own configuration
293+// already permits.
294+//
295+// The provisioned local binding wins when it names the carriage, because the
296+// local binding is the broker's own daemon authority. Otherwise the single
297+// registration that provisions this carriage supplies the fence, identity, and
298+// policy. A configuration that provisions neither yields no target, and a
299+// start-if-needed request is then refused rather than widened.
300+func helperCarriageTarget(config *brokerconfig.Config, route brokerconfig.Route) (ports.BrokerDialTarget, error) {
301+ if binding, ok := config.LocalBinding(); ok && binding.Route.Address() == route.Address() {
302+ return ports.BrokerDialTarget{
303+ Fence: ports.BrokerEndpointFence{Local: true},
304+ Address: binding.Route.Address(),
305+ Policy: binding.Policy,
306+ ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: binding.Identity, Bound: binding.Identity != ""},
307+ }, nil
308+ }
309+ for _, endpoint := range config.Endpoints() {
310+ registration, ok := config.Registration(endpoint)
311+ if !ok || registration.Route.Address() != route.Address() {
312+ continue
313+ }
314+ return ports.BrokerDialTarget{
315+ Fence: ports.BrokerEndpointFence{Registration: registration.Registration},
316+ Address: registration.Route.Address(),
317+ Policy: registration.Policy,
318+ ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: registration.Identity, Bound: registration.Identity != ""},
319+ }, nil
320+ }
321+ return ports.BrokerDialTarget{}, localDaemonStartRefused("the helper configuration provisions no daemon authority for its mux carriage")
322+}
323+
324+// dialMuxHelperCarriage dials the private daemonmux carriage one remote-side mux
325+// helper bridges to, under the start authorization the broker propagated. The
326+// helper applies the same local election the broker does, from its own
327+// broker-owned configuration, and it never starts a broker, an observer, or a
328+// recursive helper: an existing-only mode is one carriage dial, and a
329+// start-if-needed mode is the shared daemon start gated by the helper's own
330+// provisioned policy and identity.
331+func dialMuxHelperCarriage(ctx context.Context, config *brokerconfig.Config, mode ports.BrokerDaemonStartMode) (daemonmux.RawFramedTransport, error) {
332+ if config == nil {
333+ return nil, errors.New("vev: broker mux helper requires a configuration")
334+ }
335+ if err := mode.Validate(); err != nil {
336+ return nil, err
337+ }
338+ route, err := config.LocalMuxRoute()
339+ if err != nil {
340+ return nil, err
341+ }
342+ target, err := helperCarriageTarget(config, route)
343+ if err != nil {
344+ return nil, err
345+ }
346+ target.StartMode = mode
347+ if err := target.Validate(); err != nil {
348+ return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
349+ }
350+ // Only a policy that names the launch token may start the daemon. A carriage
351+ // provisioned under a non-launching policy refuses the start outright. The
352+ // existing-only mode never reaches this check: dialBrokerDaemonCarriage
353+ // returns its single dial's result immediately.
354+ if mode == ports.BrokerDaemonStartIfNeeded {
355+ if err := daemonStartRefusal(target); err != nil {
356+ return nil, err
357+ }
358+ }
359+ return dialBrokerDaemonCarriage(ctx, route.Path(), target)
360+}
A internal/app/broker_daemon_start_test.go +854 -0
@@ -0,0 +1,854 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "log/slog"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "sync/atomic"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/vev/internal/adapters/brokerconfig"
18+ "github.com/bnema/vev/internal/adapters/daemonmux"
19+ "github.com/bnema/vev/internal/ports"
20+ "github.com/bnema/vev/internal/protocol/wire"
21+)
22+
23+// Broker-owned daemon start coverage (Plan 001 P7, UI-driver slice 1).
24+//
25+// These tests drive the real start decision — the closed start authorization,
26+// the launch policy, the shared election, and the carriage dial — through the
27+// scripted starter seams. Nothing here re-executes a daemon, binds a socket, or
28+// reads a session: every assertion is about whether a spawn was elected and
29+// which carriage was dialed, which is exactly the contract the broker owns.
30+
31+// stubRawCarriage is one inert raw framed carriage: enough to satisfy the dial
32+// seam without a socket, a peer, or a goroutine.
33+type stubRawCarriage struct{ closed atomic.Bool }
34+
35+func (c *stubRawCarriage) Send(wire.Envelope) error { return nil }
36+
37+func (c *stubRawCarriage) RecvBounded(uint64) (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
38+
39+func (c *stubRawCarriage) Close() error {
40+ c.closed.Store(true)
41+ return nil
42+}
43+
44+// scriptedDaemonStarter is a goroutine-safe recording starter: it counts dials
45+// and spawns, keeps the exact target each dial received, and fails every dial
46+// until the scripted spawn (or the test) makes the carriage available.
47+type scriptedDaemonStarter struct {
48+ mu sync.Mutex
49+ carriage string
50+ // published is the carriage the scripted daemon advertises as the endpoint
51+ // this process can start. Empty means the same carriage it answers at.
52+ published string
53+ dialed []ports.BrokerDialTarget
54+ dials int
55+ spawns int
56+ // running reports that the daemon behind the carriage is published, as the
57+ // elected spawner's daemon is once it binds its endpoint.
58+ running bool
59+ // available reports whether the carriage answers. A nil func means "answer
60+ // only once a spawn happened".
61+ available func() bool
62+ // dialErr overrides the failure a dial reports while unavailable.
63+ dialErr error
64+ // spawnErr fails the elected spawn.
65+ spawnErr error
66+ // daemonOwner is the lifecycle ownership the spawned daemon holds, exactly
67+ // as the real daemon does for its whole run. It is what makes a concurrent
68+ // second caller wait instead of electing itself.
69+ daemonOwner lifecycleOwnership
70+}
71+
72+func (s *scriptedDaemonStarter) starter(backoff backoffConfig) brokerDaemonStarter {
73+ return brokerDaemonStarter{
74+ dial: s.dial,
75+ spawn: s.spawn,
76+ backoff: backoff,
77+ // The scripted daemon publishes exactly the carriage the test declared,
78+ // so the start path sees one reachable daemon endpoint like production.
79+ published: func() string { return s.publishedCarriage() },
80+ }
81+}
82+
83+// releaseDaemonOwnership drops the lifecycle ownership the scripted daemon held,
84+// as stopping it would.
85+func (s *scriptedDaemonStarter) releaseDaemonOwnership(t *testing.T) {
86+ t.Helper()
87+ s.mu.Lock()
88+ owner := s.daemonOwner
89+ s.daemonOwner = nil
90+ s.mu.Unlock()
91+ if owner != nil {
92+ require.NoError(t, owner.Release())
93+ }
94+}
95+
96+func (s *scriptedDaemonStarter) dial(_ context.Context, carriage string) (daemonmux.RawFramedTransport, error) {
97+ s.mu.Lock()
98+ defer s.mu.Unlock()
99+ s.dials++
100+ s.dialed = append(s.dialed, ports.BrokerDialTarget{Address: carriage})
101+ if s.carriage != carriage {
102+ return nil, os.ErrNotExist
103+ }
104+ if s.available == nil {
105+ if s.spawns == 0 && !s.running {
106+ return nil, os.ErrNotExist
107+ }
108+ } else if !s.available() {
109+ if s.dialErr != nil {
110+ return nil, s.dialErr
111+ }
112+ return nil, os.ErrNotExist
113+ }
114+ return &stubRawCarriage{}, nil
115+}
116+
117+func (s *scriptedDaemonStarter) spawn() error {
118+ s.mu.Lock()
119+ defer s.mu.Unlock()
120+ s.spawns++
121+ if s.spawnErr != nil {
122+ return s.spawnErr
123+ }
124+ // A real spawned daemon immediately takes lifecycle ownership of the
125+ // runtime directory and holds it for its whole run; a concurrent caller
126+ // therefore observes ErrBusy and waits for the carriage rather than
127+ // electing a second spawner.
128+ lockDir := filepath.Dir(s.publishedCarriageLocked())
129+ if owner, err := daemonLifecycleProbe.TryAcquire(lockDir); err == nil {
130+ s.daemonOwner = owner
131+ }
132+ return nil
133+}
134+
135+// markRunning publishes the daemon the elected spawner started, so the callers
136+// waiting on the held election converge on its carriage.
137+func (s *scriptedDaemonStarter) markRunning() {
138+ s.mu.Lock()
139+ defer s.mu.Unlock()
140+ s.running = true
141+}
142+
143+// isRunning reports whether the daemon is published.
144+func (s *scriptedDaemonStarter) isRunning() bool {
145+ s.mu.Lock()
146+ defer s.mu.Unlock()
147+ return s.running
148+}
149+
150+// publishedCarriageLocked reports the published carriage while the mutex is
151+// already held.
152+func (s *scriptedDaemonStarter) publishedCarriageLocked() string {
153+ if s.published != "" {
154+ return s.published
155+ }
156+ return s.carriage
157+}
158+
159+// publishedCarriage reports the carriage the scripted daemon publishes as the
160+// endpoint this process can start.
161+func (s *scriptedDaemonStarter) publishedCarriage() string {
162+ s.mu.Lock()
163+ defer s.mu.Unlock()
164+ return s.publishedCarriageLocked()
165+}
166+
167+func (s *scriptedDaemonStarter) counters() (dials, spawns int) {
168+ s.mu.Lock()
169+ defer s.mu.Unlock()
170+ return s.dials, s.spawns
171+}
172+
173+// fastDaemonStartBackoff bounds the election the way an unresponsive daemon
174+// would, without the production five-second budget.
175+var fastDaemonStartBackoff = backoffConfig{initial: time.Millisecond, max: 2 * time.Millisecond, total: 150 * time.Millisecond}
176+
177+// withDaemonStarter swaps the package start seam for one test and restores it.
178+// The start seam is process state, so no caller of this helper runs in parallel.
179+func withDaemonStarter(t *testing.T, starter brokerDaemonStarter) {
180+ t.Helper()
181+ restore := localDaemonStarter
182+ localDaemonStarter = starter
183+ t.Cleanup(func() { localDaemonStarter = restore })
184+}
185+
186+// testDaemonCarriage returns a fresh owner-only daemon runtime directory and the
187+// exact private carriage a daemon bound to it publishes. It is the carriage the
188+// scripted starter declares, so the start path sees one reachable endpoint.
189+func testDaemonCarriage(t *testing.T) (dir, carriage string) {
190+ t.Helper()
191+ dir = t.TempDir()
192+ // The lifecycle lock refuses a directory with group or other access, and
193+ // t.TempDir() is 0755; the daemon runtime directory is owner-only.
194+ require.NoError(t, os.Chmod(dir, 0o700))
195+ return dir, daemonmux.SocketPath(dir)
196+}
197+
198+// startableLocalTarget builds the resolved local target the broker publishes for
199+// its own machine daemon under the supplied start authorization. The policy is
200+// the production local daemon authority, which explicitly names the launch
201+// token; a test that needs a non-launching policy overrides it. The address is
202+// the bounded token the resolver would publish for the provisioned carriage.
203+func startableLocalTarget(t *testing.T, carriage string, mode ports.BrokerDaemonStartMode) ports.BrokerDialTarget {
204+ t.Helper()
205+ _, binding := testLocalSandboxConfigForCarriage(t, carriage)
206+ target := ports.BrokerDialTarget{
207+ Fence: ports.BrokerEndpointFence{Local: true},
208+ Address: binding.Route.Address(),
209+ Policy: localDaemonPolicy(),
210+ StartMode: mode,
211+ ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: brokerLocalTestIdentity, Bound: true},
212+ }
213+ require.NoError(t, target.Validate())
214+ return target
215+}
216+
217+// writePrivateSandboxConfig writes one marker-valid sandbox whose single local
218+// binding is the supplied private carriage under the supplied policy.
219+func writePrivateSandboxConfig(t *testing.T, root, carriage string, policy ports.BrokerPolicy) {
220+ t.Helper()
221+ writeSandboxConfig(t, root, map[string]any{
222+ "marker": brokerconfig.Marker,
223+ "registrations": []any{},
224+ "local": map[string]any{
225+ "identity": brokerLocalTestIdentity,
226+ "displayOrigin": "local",
227+ "route": carriage,
228+ "policy": map[string]any{
229+ "protocolVersion": policy.ProtocolVersion,
230+ "catalogSchemaVersion": policy.CatalogSchemaVersion,
231+ "environmentPolicy": "client-owned",
232+ "transport": policy.Transport,
233+ "trust": policy.Trust,
234+ "launch": policy.Launch,
235+ "isolation": policy.Isolation,
236+ },
237+ },
238+ })
239+}
240+
241+// TestDaemonStartExistingOnlyNeverSpawns proves an existing-only acquisition is
242+// exactly one carriage dial: no lifecycle probe, no spawn lock, no process, and
243+// no fallback of any kind, even though the carriage is absent.
244+func TestDaemonStartExistingOnlyNeverSpawns(t *testing.T) {
245+ isolateSandboxEnv(t)
246+ dir, carriage := testDaemonCarriage(t)
247+ scripted := &scriptedDaemonStarter{carriage: carriage}
248+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
249+
250+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly)
251+ raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target)
252+ require.ErrorIs(t, err, os.ErrNotExist)
253+ require.Nil(t, raw)
254+
255+ dials, spawns := scripted.counters()
256+ require.Equal(t, 1, dials, "existing-only is exactly one carriage dial")
257+ require.Zero(t, spawns, "existing-only must never spawn")
258+ _, statErr := os.Stat(filepath.Join(dir, spawnLockName))
259+ require.ErrorIs(t, statErr, os.ErrNotExist, "existing-only must not take the spawn lock")
260+ _, probeErr := os.Stat(filepath.Join(dir, "lifecycle.lock"))
261+ require.ErrorIs(t, probeErr, os.ErrNotExist, "existing-only must not probe lifecycle ownership")
262+}
263+
264+// TestDaemonStartExistingOnlyReturnsRunningDaemonWithoutProbing proves a
265+// reachable carriage is the whole result: the running daemon is never restarted
266+// and no election is entered.
267+func TestDaemonStartExistingOnlyReturnsRunningDaemonWithoutProbing(t *testing.T) {
268+ isolateSandboxEnv(t)
269+ dir, carriage := testDaemonCarriage(t)
270+ scripted := &scriptedDaemonStarter{carriage: carriage, available: func() bool { return true }}
271+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
272+
273+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly)
274+ raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target)
275+ require.NoError(t, err)
276+ require.NotNil(t, raw)
277+
278+ // A start-if-needed acquisition against the same running daemon also
279+ // returns it unchanged: the mode authorizes a start, it never demands one.
280+ startIfNeeded := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded)
281+ rawStartIfNeeded, err := dialBrokerLocalDaemon(context.Background(), carriage, startIfNeeded)
282+ require.NoError(t, err)
283+ require.NotNil(t, rawStartIfNeeded)
284+
285+ dials, spawns := scripted.counters()
286+ require.Equal(t, 2, dials)
287+ require.Zero(t, spawns, "a running daemon is never restarted")
288+ _, probeErr := os.Stat(filepath.Join(dir, "lifecycle.lock"))
289+ require.ErrorIs(t, probeErr, os.ErrNotExist, "a reachable carriage never enters the election")
290+}
291+
292+// TestDaemonStartIfNeededSpawnsExactlyOnce proves the authorized start path
293+// elects one spawner, then returns the carriage the spawned daemon published.
294+func TestDaemonStartIfNeededSpawnsExactlyOnce(t *testing.T) {
295+ isolateSandboxEnv(t)
296+ dir, carriage := testDaemonCarriage(t)
297+ scripted := &scriptedDaemonStarter{carriage: carriage}
298+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
299+
300+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded)
301+ raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target)
302+ require.NoError(t, err)
303+ require.NotNil(t, raw)
304+
305+ dials, spawns := scripted.counters()
306+ require.GreaterOrEqual(t, dials, 2, "the start path dials before and after the spawn")
307+ require.Equal(t, 1, spawns)
308+ // The election released the spawn lock.
309+ require.NoFileExists(t, filepath.Join(dir, spawnLockName))
310+}
311+
312+// TestDaemonStartRefusesPolicyWithoutLaunchAuthority proves the closed start
313+// authorization can only narrow the resolved policy: a policy that does not name
314+// the broker launch authority refuses the start outright, and nothing spawns.
315+func TestDaemonStartRefusesPolicyWithoutLaunchAuthority(t *testing.T) {
316+ isolateSandboxEnv(t)
317+ dir, carriage := testDaemonCarriage(t)
318+ scripted := &scriptedDaemonStarter{carriage: carriage}
319+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
320+
321+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded)
322+ target.Policy.Launch = "sandbox-private"
323+ require.NoError(t, target.Validate())
324+
325+ raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target)
326+ require.Error(t, err)
327+ require.Nil(t, raw)
328+ var typed ports.BrokerError
329+ require.ErrorAs(t, err, &typed)
330+ require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code)
331+
332+ dials, spawns := scripted.counters()
333+ require.Equal(t, 1, dials, "the carriage is dialed once before the refusal")
334+ require.Zero(t, spawns, "a non-launching policy must never spawn")
335+ require.NoFileExists(t, filepath.Join(dir, spawnLockName))
336+}
337+
338+// TestDaemonStartRefusesRegisteredEndpoint proves the local start mechanics
339+// refuse a registered endpoint even on a local carriage: the broker owns the
340+// start authority of its own machine daemon only.
341+func TestDaemonStartRefusesRegisteredEndpoint(t *testing.T) {
342+ isolateSandboxEnv(t)
343+ _, carriage := testDaemonCarriage(t)
344+ scripted := &scriptedDaemonStarter{carriage: carriage}
345+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
346+
347+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded)
348+ target.Fence = ports.BrokerEndpointFence{Registration: brokerTestRegistration()}
349+ require.NoError(t, target.Validate())
350+
351+ _, err := dialBrokerLocalDaemon(context.Background(), carriage, target)
352+ require.Error(t, err)
353+ dials, spawns := scripted.counters()
354+ require.Zero(t, dials, "a registered endpoint is refused before any dial")
355+ require.Zero(t, spawns)
356+}
357+
358+// TestDaemonStartRefusesCarriageOutsideTheProvisionedRoute proves a carriage that
359+// is not an absolute cleaned private mux path is refused before any dial, so a
360+// hand-built target can never make the launcher start a daemon for a foreign
361+// endpoint.
362+func TestDaemonStartRefusesCarriageOutsideTheProvisionedRoute(t *testing.T) {
363+ isolateSandboxEnv(t)
364+ _, carriage := testDaemonCarriage(t)
365+ scripted := &scriptedDaemonStarter{carriage: carriage}
366+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
367+
368+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded)
369+ for _, invalid := range []string{"", "relative/daemonmux.sock", carriage + "/.."} {
370+ _, err := dialBrokerLocalDaemon(context.Background(), invalid, target)
371+ require.Error(t, err)
372+ var typed ports.BrokerError
373+ require.ErrorAs(t, err, &typed)
374+ require.Equal(t, ports.BrokerErrorIncompatible, typed.Code)
375+ }
376+ dials, spawns := scripted.counters()
377+ require.Zero(t, dials)
378+ require.Zero(t, spawns)
379+}
380+
381+// TestDaemonStartRefusesForeignCarriageStart proves a start is refused for a
382+// carriage this process cannot publish: the launcher re-executes this binary,
383+// which binds its own private endpoint, so starting a daemon for a foreign
384+// carriage would spawn a process the caller could never reach.
385+func TestDaemonStartRefusesForeignCarriageStart(t *testing.T) {
386+ isolateSandboxEnv(t)
387+ _, own := testDaemonCarriage(t)
388+ foreignDir := t.TempDir()
389+ require.NoError(t, os.Chmod(foreignDir, 0o700))
390+ foreign := filepath.Join(foreignDir, "daemonmux.sock")
391+
392+ // The scripted daemon publishes and answers at the process' own carriage;
393+ // the dial target names a different one.
394+ scripted := &scriptedDaemonStarter{carriage: own, published: own}
395+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
396+
397+ target := startableLocalTarget(t, foreign, ports.BrokerDaemonStartIfNeeded)
398+ _, err := dialBrokerDaemonCarriage(context.Background(), foreign, target)
399+ require.Error(t, err)
400+ var typed ports.BrokerError
401+ require.ErrorAs(t, err, &typed)
402+ require.Equal(t, ports.BrokerErrorIncompatible, typed.Code)
403+
404+ dials, spawns := scripted.counters()
405+ require.Equal(t, 1, dials, "the carriage is dialed once before the refusal")
406+ require.Zero(t, spawns, "a foreign carriage must never be started")
407+ require.NoFileExists(t, filepath.Join(filepath.Dir(own), spawnLockName))
408+}
409+
410+// TestDaemonStartDoesNotSpawnOnNonAbsenceFailure proves only proven absence can
411+// authorize a start: a permission, path, peer, or cancellation failure is
412+// reported unchanged and never repaired by spawning a second daemon.
413+func TestDaemonStartDoesNotSpawnOnNonAbsenceFailure(t *testing.T) {
414+ tests := []struct {
415+ name string
416+ err error
417+ }{
418+ {name: "permission", err: os.ErrPermission},
419+ {name: "context canceled", err: context.Canceled},
420+ {name: "invalid path", err: os.ErrInvalid},
421+ {name: "unclassified", err: errors.New("carriage refused")},
422+ }
423+ for _, tt := range tests {
424+ t.Run(tt.name, func(t *testing.T) {
425+ isolateSandboxEnv(t)
426+ dir, carriage := testDaemonCarriage(t)
427+ scripted := &scriptedDaemonStarter{
428+ carriage: carriage,
429+ available: func() bool { return false },
430+ dialErr: tt.err,
431+ }
432+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
433+
434+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded)
435+ _, err := dialBrokerLocalDaemon(context.Background(), carriage, target)
436+ require.ErrorIs(t, err, tt.err)
437+
438+ dials, spawns := scripted.counters()
439+ require.Equal(t, 1, dials)
440+ require.Zero(t, spawns)
441+ require.NoFileExists(t, filepath.Join(dir, spawnLockName))
442+ })
443+ }
444+}
445+
446+// TestDaemonStartConcurrentRequestsShareOneElection proves N concurrent
447+// start-if-needed requests never elect a second spawner while one election is
448+// already in progress: the spawn lock is held by the elected spawner, so every
449+// other caller waits for the carriage it publishes instead of spawning again.
450+//
451+// The spawn lock is taken here exactly as another client's elected spawner
452+// would take it, and the carriage becomes dialable once that "spawner" finishes.
453+// The starter therefore records zero spawns of its own, every caller converges
454+// on the published carriage, and no caller ever holds the lock itself.
455+func TestDaemonStartConcurrentRequestsShareOneElection(t *testing.T) {
456+ isolateSandboxEnv(t)
457+ dir, carriage := testDaemonCarriage(t)
458+ scripted := &scriptedDaemonStarter{carriage: carriage}
459+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
460+
461+ // Another client's elected spawner holds the lock; the daemon it starts
462+ // publishes the carriage shortly afterwards.
463+ release, acquired, err := acquireSpawnLock(dir)
464+ require.NoError(t, err)
465+ require.True(t, acquired, "the test must hold the spawn lock")
466+ t.Cleanup(release)
467+
468+ // The target is resolved once: require.* is not goroutine-safe, and the
469+ // resolved target is immutable and shared by every caller.
470+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded)
471+
472+ const callers = 8
473+ var wg sync.WaitGroup
474+ results := make([]daemonmux.RawFramedTransport, callers)
475+ errs := make([]error, callers)
476+ start := make(chan struct{})
477+ for i := range callers {
478+ wg.Add(1)
479+ go func() {
480+ defer wg.Done()
481+ <-start
482+ results[i], errs[i] = dialBrokerLocalDaemon(context.Background(), carriage, target)
483+ }()
484+ }
485+ // Publish the daemon the held election is starting, as its spawner does.
486+ go func() {
487+ <-start
488+ scripted.markRunning()
489+ }()
490+ close(start)
491+ // The held election ends once the carriage answers.
492+ require.Eventually(t, func() bool { return scripted.isRunning() }, brokerTestWait, time.Millisecond)
493+ release()
494+ wg.Wait()
495+
496+ for i := range callers {
497+ require.NoError(t, errs[i], "caller %d", i)
498+ require.NotNil(t, results[i], "caller %d", i)
499+ }
500+ _, spawns := scripted.counters()
501+ require.Zero(t, spawns, "a caller must never spawn while another election holds the lock")
502+}
503+
504+// TestDaemonStartConcurrentExistingOnlyNeverSpawns proves concurrent
505+// existing-only acquisitions never share a start: each one dials, fails on the
506+// same absent carriage, and spawns nothing.
507+func TestDaemonStartConcurrentExistingOnlyNeverSpawns(t *testing.T) {
508+ isolateSandboxEnv(t)
509+ _, carriage := testDaemonCarriage(t)
510+ scripted := &scriptedDaemonStarter{carriage: carriage}
511+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
512+
513+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly)
514+ const callers = 8
515+ var wg sync.WaitGroup
516+ start := make(chan struct{})
517+ for range callers {
518+ wg.Add(1)
519+ go func() {
520+ defer wg.Done()
521+ <-start
522+ _, err := dialBrokerLocalDaemon(context.Background(), carriage, target)
523+ require.ErrorIs(t, err, os.ErrNotExist)
524+ }()
525+ }
526+ close(start)
527+ wg.Wait()
528+
529+ dials, spawns := scripted.counters()
530+ require.Equal(t, callers, dials)
531+ require.Zero(t, spawns)
532+}
533+
534+// TestDaemonStartPropagatesTheWholeTarget proves the resolved target reaches the
535+// carriage owner whole: address, fence, policy, start authorization, and expected
536+// identity all travel together, so a dial can never act on an authorization the
537+// connector did not validate.
538+func TestDaemonStartPropagatesTheWholeTarget(t *testing.T) {
539+ isolateSandboxEnv(t)
540+ _, carriage := testDaemonCarriage(t)
541+
542+ var seen []ports.BrokerDialTarget
543+ var mu sync.Mutex
544+ connectorDial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
545+ mu.Lock()
546+ seen = append(seen, target)
547+ mu.Unlock()
548+ return nil, os.ErrNotExist
549+ }
550+ connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(connectorDial), daemonmux.DefaultMuxCeilings())
551+ require.NoError(t, err)
552+
553+ // The pool-side connector already owns the whole target; the local start
554+ // helper is what turns it into the carriage decision.
555+ scripted := &scriptedDaemonStarter{carriage: carriage}
556+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
557+
558+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly)
559+ raw, err := dialBrokerLocalDaemon(context.Background(), carriage, target)
560+ require.Error(t, err)
561+ require.Nil(t, raw)
562+
563+ // The exact same target value is what a connector hands to its dial
564+ // function, so the local helper observes the identical authorization.
565+ _, err = connector.Connect(context.Background(), target)
566+ require.Error(t, err)
567+ mu.Lock()
568+ require.Equal(t, []ports.BrokerDialTarget{target}, seen, "the connector dials the whole resolved target")
569+ mu.Unlock()
570+ require.Equal(t, ports.BrokerDaemonExistingOnly, seen[0].StartMode)
571+ require.True(t, seen[0].Fence.Local)
572+ require.Equal(t, target.Policy, seen[0].Policy)
573+ require.Equal(t, target.ExpectedIdentity, seen[0].ExpectedIdentity)
574+}
575+
576+// TestDaemonStartNeverFallsBackToASessionDial proves structurally that the
577+// start path owns no session carriage: it never dials the daemon's public
578+// session socket and never wraps a session transport.
579+func TestDaemonStartNeverFallsBackToASessionDial(t *testing.T) {
580+ sources := loadAppSources(t, false)
581+ body, ok := sources["broker_daemon_start.go"]
582+ require.True(t, ok, "the daemon start owner must exist")
583+
584+ for _, forbidden := range []string{
585+ "ipc.DialContext(",
586+ "ipc.SocketPath(",
587+ "sessionwire.",
588+ "localDaemonDialer",
589+ "ensureDaemonWithLifecycle",
590+ "ensureDaemon(",
591+ } {
592+ require.NotContains(t, body, forbidden, "the daemon start owner must not reach a session carriage")
593+ }
594+ // The only transport it hands out is the daemonmux carriage.
595+ require.Contains(t, body, "daemonmux.RawFramedTransport")
596+}
597+
598+// TestDaemonStartRefusesIncompleteStarter proves a start seam that cannot dial
599+// or spawn fails closed instead of dialing a session or panicking.
600+func TestDaemonStartRefusesIncompleteStarter(t *testing.T) {
601+ isolateSandboxEnv(t)
602+ _, carriage := testDaemonCarriage(t)
603+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded)
604+
605+ for _, starter := range []brokerDaemonStarter{{}, {dial: dialLocalDaemonCarriage}, {spawn: realSpawn}} {
606+ withDaemonStarter(t, starter)
607+ _, err := dialBrokerLocalDaemon(context.Background(), carriage, target)
608+ require.Error(t, err)
609+ var typed ports.BrokerError
610+ require.ErrorAs(t, err, &typed)
611+ require.Equal(t, ports.BrokerErrorIncompatible, typed.Code)
612+ }
613+}
614+
615+// TestDaemonStartRefusesInvalidTarget proves an unvalidated resolved target is
616+// refused before any dial or spawn.
617+func TestDaemonStartRefusesInvalidTarget(t *testing.T) {
618+ isolateSandboxEnv(t)
619+ _, carriage := testDaemonCarriage(t)
620+ scripted := &scriptedDaemonStarter{carriage: carriage}
621+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
622+
623+ invalid := startableLocalTarget(t, carriage, ports.BrokerDaemonStartIfNeeded)
624+ invalid.StartMode = 0
625+ _, err := dialBrokerLocalDaemon(context.Background(), carriage, invalid)
626+ require.Error(t, err)
627+ var typed ports.BrokerError
628+ require.ErrorAs(t, err, &typed)
629+ require.Equal(t, ports.BrokerErrorIncompatible, typed.Code)
630+ dials, spawns := scripted.counters()
631+ require.Zero(t, dials)
632+ require.Zero(t, spawns)
633+}
634+
635+// helperSandboxConfigForCarriage provisions one sandbox whose single
636+// registration bridges the supplied private Unix carriage under the supplied
637+// policy, and returns its loaded configuration. A remote-side mux helper owns
638+// exactly this shape: the provisioning is the helper's own authority.
639+func helperSandboxConfigForCarriage(t *testing.T, carriage string, policy ports.BrokerPolicy) *brokerconfig.Config {
640+ t.Helper()
641+ root := filepath.Dir(carriage)
642+ writeSandboxConfig(t, root, map[string]any{
643+ "marker": brokerconfig.Marker,
644+ "registrations": []any{map[string]any{
645+ "endpoint": brokerTestEndpoint,
646+ "incarnation": "0102030405060708090a0b0c0d0e0f10",
647+ "generation": 1,
648+ "identity": brokerTestIdentity,
649+ "route": carriage,
650+ "policy": map[string]any{
651+ "protocolVersion": policy.ProtocolVersion,
652+ "catalogSchemaVersion": policy.CatalogSchemaVersion,
653+ "environmentPolicy": "client-owned",
654+ "transport": policy.Transport,
655+ "trust": policy.Trust,
656+ "launch": policy.Launch,
657+ "isolation": policy.Isolation,
658+ },
659+ }},
660+ })
661+ config, err := brokerconfig.Load(mustOfflineLayout(t, root))
662+ require.NoError(t, err)
663+ return config
664+}
665+
666+// TestMuxHelperCarriageStartAuthorization proves the remote-side helper applies
667+// the propagated authorization against its own provisioned authority: an
668+// existing-only request is one carriage dial, a start-if-needed request is gated
669+// by the helper's own policy, and an unknown mode is refused outright.
670+func TestMuxHelperCarriageStartAuthorization(t *testing.T) {
671+ isolateSandboxEnv(t)
672+ dir, carriage := testDaemonCarriage(t)
673+ config := helperSandboxConfigForCarriage(t, carriage, brokerTestPolicy())
674+
675+ scripted := &scriptedDaemonStarter{carriage: carriage}
676+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
677+
678+ _, err := dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonExistingOnly)
679+ require.ErrorIs(t, err, os.ErrNotExist)
680+ dials, spawns := scripted.counters()
681+ require.Equal(t, 1, dials, "existing-only is exactly one carriage dial")
682+ require.Zero(t, spawns)
683+
684+ // The provisioned policy does not name the launch authority, so a
685+ // start-if-needed request is refused before any dial and never spawns: the
686+ // helper's own authority is static, so the refusal is decided up front.
687+ _, err = dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartIfNeeded)
688+ require.Error(t, err)
689+ var typed ports.BrokerError
690+ require.ErrorAs(t, err, &typed)
691+ require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code)
692+ dials, spawns = scripted.counters()
693+ require.Equal(t, 1, dials, "a policy refusal is decided before any dial")
694+ require.Zero(t, spawns, "a non-launching helper policy must never spawn")
695+ require.NoFileExists(t, filepath.Join(dir, spawnLockName))
696+
697+ // An unknown mode and a missing configuration are never defaulted.
698+ _, err = dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartMode(9))
699+ require.Error(t, err)
700+ _, err = dialMuxHelperCarriage(context.Background(), nil, ports.BrokerDaemonExistingOnly)
701+ require.Error(t, err)
702+ dials, spawns = scripted.counters()
703+ require.Equal(t, 1, dials, "a refused mode never reaches a dial")
704+ require.Zero(t, spawns)
705+}
706+
707+// TestMuxHelperCarriageStartsUnderItsOwnLaunchAuthority proves the helper starts
708+// the daemon only when its own provisioned policy authorizes launching, and that
709+// it starts the daemon published on its own machine rather than another broker.
710+func TestMuxHelperCarriageStartsUnderItsOwnLaunchAuthority(t *testing.T) {
711+ isolateSandboxEnv(t)
712+ _, carriage := testDaemonCarriage(t)
713+ config := helperSandboxConfigForCarriage(t, carriage, brokerTestPolicyForLaunch())
714+
715+ scripted := &scriptedDaemonStarter{carriage: carriage}
716+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
717+
718+ raw, err := dialMuxHelperCarriage(context.Background(), config, ports.BrokerDaemonStartIfNeeded)
719+ require.NoError(t, err)
720+ require.NotNil(t, raw)
721+ _, spawns := scripted.counters()
722+ require.Equal(t, 1, spawns)
723+}
724+
725+// brokerTestPolicyForLaunch is the sandbox policy with the launch authority the
726+// broker's own production local daemon names, so a helper test can exercise the
727+// authorized start path without provisioning production layout.
728+func brokerTestPolicyForLaunch() ports.BrokerPolicy {
729+ policy := brokerTestPolicy()
730+ policy.Launch = brokerLaunchAuthority
731+ return policy
732+}
733+
734+// TestDaemonStartArgvFlagRoundTrips proves the helper argument spelling is one
735+// closed value both sides agree on, and that an unknown value is refused in
736+// either direction.
737+func TestDaemonStartArgvFlagRoundTrips(t *testing.T) {
738+ tests := []struct {
739+ mode ports.BrokerDaemonStartMode
740+ flag string
741+ }{
742+ {mode: ports.BrokerDaemonExistingOnly, flag: "existing-only"},
743+ {mode: ports.BrokerDaemonStartIfNeeded, flag: "if-needed"},
744+ }
745+ for _, tt := range tests {
746+ flag, err := brokerDaemonStartArgvFlag(tt.mode)
747+ require.NoError(t, err)
748+ require.Equal(t, tt.flag, flag)
749+ parsed, err := parseBrokerDaemonStartArgvFlag(flag)
750+ require.NoError(t, err)
751+ require.Equal(t, tt.mode, parsed)
752+ }
753+ _, err := brokerDaemonStartArgvFlag(ports.BrokerDaemonStartMode(0))
754+ require.Error(t, err)
755+ for _, invalid := range []string{"", "existing_only", "if_needed", "always", "Existing-Only"} {
756+ _, err := parseBrokerDaemonStartArgvFlag(invalid)
757+ require.Error(t, err, "value %q must be refused", invalid)
758+ }
759+}
760+
761+// testLocalSandboxConfigForCarriage provisions one sandbox whose single local
762+// binding is the supplied private carriage, and returns its loaded
763+// configuration and binding.
764+func testLocalSandboxConfigForCarriage(t *testing.T, carriage string) (*brokerconfig.Config, brokerconfig.LocalBinding) {
765+ t.Helper()
766+ root := filepath.Dir(carriage)
767+ writePrivateSandboxConfig(t, root, carriage, brokerTestPolicy())
768+ layout := mustOfflineLayout(t, root)
769+ config, err := brokerconfig.Load(layout)
770+ require.NoError(t, err)
771+ binding, ok := config.LocalBinding()
772+ require.True(t, ok)
773+ return config, binding
774+}
775+
776+func mustOfflineLayout(t *testing.T, root string) brokerconfig.Layout {
777+ t.Helper()
778+ layout, err := offlineLayout(root)
779+ require.NoError(t, err)
780+ return layout
781+}
782+
783+func mustLocalMuxAddress(t *testing.T, config *brokerconfig.Config) string {
784+ t.Helper()
785+ route, err := config.LocalMuxRoute()
786+ require.NoError(t, err)
787+ return route.Address()
788+}
789+
790+// TestBrokerMuxConnectorObservationNeverSpawns proves the composed broker
791+// connector dials the broker-owned local carriage for an observation under the
792+// existing-only authorization and spawns nothing, so observing a stopped local
793+// daemon stays a reported failure rather than a start.
794+func TestBrokerMuxConnectorObservationNeverSpawns(t *testing.T) {
795+ isolateSandboxEnv(t)
796+ _, carriage := testDaemonCarriage(t)
797+ config, binding := testLocalSandboxConfigForCarriage(t, carriage)
798+
799+ scripted := &scriptedDaemonStarter{carriage: carriage}
800+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
801+
802+ connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil)))
803+ require.NoError(t, err)
804+ target, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{
805+ Purpose: ports.BrokerStreamObservation,
806+ Local: true,
807+ Stream: 1,
808+ Policy: binding.Policy,
809+ StartMode: ports.BrokerDaemonExistingOnly,
810+ })
811+ require.NoError(t, err)
812+
813+ _, err = connector.Connect(context.Background(), target)
814+ require.ErrorIs(t, err, os.ErrNotExist)
815+ dials, spawns := scripted.counters()
816+ require.Equal(t, 1, dials, "the observation dials its local carriage once")
817+ require.Zero(t, spawns, "an observation must never start a daemon")
818+}
819+
820+// TestBrokerMuxConnectorRefusesMismatchedAuthority proves the transport never
821+// dials under an authority the configuration did not provision: a target whose
822+// policy or address no longer matches the route is refused before any dial.
823+func TestBrokerMuxConnectorRefusesMismatchedAuthority(t *testing.T) {
824+ isolateSandboxEnv(t)
825+ _, carriage := testDaemonCarriage(t)
826+ config, _ := testLocalSandboxConfigForCarriage(t, carriage)
827+
828+ scripted := &scriptedDaemonStarter{carriage: carriage}
829+ withDaemonStarter(t, scripted.starter(fastDaemonStartBackoff))
830+
831+ connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil)))
832+ require.NoError(t, err)
833+
834+ target := startableLocalTarget(t, carriage, ports.BrokerDaemonExistingOnly)
835+ // The address is provisioned, but the policy is not.
836+ target.Policy.Launch = "other-launch"
837+ require.NoError(t, target.Validate())
838+ _, err = connector.Connect(context.Background(), target)
839+ require.Error(t, err)
840+ var typed ports.BrokerError
841+ require.ErrorAs(t, err, &typed)
842+ require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code)
843+
844+ // An address the configuration never produced is refused too.
845+ unknown := target
846+ unknown.Address = "0000000000000000000000000000000000000000000000000000000000000000"
847+ require.NoError(t, unknown.Validate())
848+ _, err = connector.Connect(context.Background(), unknown)
849+ require.Error(t, err)
850+
851+ dials, spawns := scripted.counters()
852+ require.Zero(t, dials, "an unmatched authority must never reach a dial")
853+ require.Zero(t, spawns)
854+}
M internal/app/broker_offline.go +194 -27
@@ -8,7 +8,9 @@ import (
88 "fmt"
99 "io"
1010 "log/slog"
11+ "os"
1112 "os/signal"
13+ "path/filepath"
1214 "strings"
1315 "syscall"
1416 "time"
@@ -17,6 +19,8 @@ import (
1719 "github.com/bnema/vev/internal/adapters/brokeripc"
1820 "github.com/bnema/vev/internal/adapters/brokerstore"
1921 "github.com/bnema/vev/internal/adapters/clock"
22+ "github.com/bnema/vev/internal/adapters/daemonidentity"
23+ "github.com/bnema/vev/internal/adapters/daemonmux"
2024 "github.com/bnema/vev/internal/adapters/ipc"
2125 "github.com/bnema/vev/internal/adapters/lifecycle"
2226 "github.com/bnema/vev/internal/logging"
@@ -42,7 +46,10 @@ import (
4246 // until a signal or its parent context ends it, or until the broker idles out,
4347 // and then shuts down in a fixed order.
4448
45-const brokerServeCommand = "_broker-serve"
49+const (
50+ brokerServeCommand = "_broker-serve"
51+ productionBrokerServeCommand = "_broker-production-serve"
52+)
4653
4754 // brokerServePoolLimits bound the offline broker pool. They are explicit, not
4855 // derived from the environment, and small enough that a stalled peer is
@@ -58,6 +65,7 @@ var brokerServePoolLimits = broker.PoolLimits{
5865 // brokerServeOptions is the parsed hidden invocation.
5966 type brokerServeOptions struct {
6067 offlineRoot string
68+ production bool
6169 // idleGrace is zero when the operator did not override it; the supervisor
6270 // then applies its own 5m default.
6371 idleGrace time.Duration
@@ -67,6 +75,13 @@ type brokerServeOptions struct {
6775 // [--idle-grace DURATION]`. Unknown flags, duplicate flags, positionals, a
6876 // missing value, and a non-positive or unparsable duration are refused; the
6977 // path itself is validated later against the filesystem.
78+func parseProductionBrokerServeArgs(args []string) (command, error) {
79+ if len(args) != 0 {
80+ return command{}, usagef("`%s` does not accept arguments", productionBrokerServeCommand)
81+ }
82+ return command{kind: kindProductionBrokerServe, brokerServe: brokerServeOptions{production: true}}, nil
83+}
84+
7085 func parseBrokerServeArgs(args []string) (command, error) {
7186 if len(args) == 0 {
7287 return command{}, usagef("`%s` requires --offline-root", brokerServeCommand)
@@ -118,6 +133,29 @@ func parseBrokerServeArgs(args []string) (command, error) {
118133 // brokerServeDeps are the composition seams. Production supplies the real
119134 // adapters; tests override the clock, the owner lock, the logger, and the
120135 // readiness hook so idle shutdown and cleanup are deterministic.
136+type productionIdentityBinder struct {
137+ delegate ports.BrokerIdentityBinder
138+ stateDir string
139+ policy ports.BrokerPolicy
140+}
141+
142+func (b productionIdentityBinder) BindAuthenticatedIdentity(ctx context.Context, request ports.BrokerIdentityBindingRequest) (ports.BrokerDaemonIdentity, error) {
143+ if !request.Fence.Local {
144+ return b.delegate.BindAuthenticatedIdentity(ctx, request)
145+ }
146+ if request.Policy != b.policy {
147+ return "", errors.New("vev: local daemon policy authority changed")
148+ }
149+ identity, err := daemonidentity.Load(b.stateDir)
150+ if err != nil {
151+ return "", fmt.Errorf("vev: revalidate daemon authority: %w", err)
152+ }
153+ if identity != request.Identity {
154+ return "", errors.New("vev: local daemon identity authority changed")
155+ }
156+ return identity, nil
157+}
158+
121159 type brokerServeDeps struct {
122160 clock func() ports.Clock
123161 acquire func(string) (lifecycleOwnership, error)
@@ -144,9 +182,23 @@ func defaultBrokerServeDeps() brokerServeDeps {
144182
145183 // runBrokerServeCommand enters the hidden foreground process: it installs
146184 // SIGINT/SIGTERM cancellation over the parent context and runs the sandbox.
147-func runBrokerServeCommand(ctx context.Context, options brokerServeOptions) error {
185+func runBrokerServeCommand(ctx context.Context, options brokerServeOptions) (retErr error) {
148186 ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
149187 defer stop()
188+ var observerCloser io.Closer
189+ if options.production {
190+ observer, closer, err := newPerformanceTrace(clock.New())
191+ if err != nil {
192+ return fmt.Errorf("vev: performance trace: %w", err)
193+ }
194+ observerCloser = closer
195+ if observer != nil {
196+ observer.ObserveRuntime(ports.NewRuntimeMark("broker", ports.RuntimeTransportDiagnostic, 0, true))
197+ }
198+ }
199+ if observerCloser != nil {
200+ defer func() { retErr = errors.Join(retErr, observerCloser.Close()) }()
201+ }
150202 return runBrokerServe(ctx, options, defaultBrokerServeDeps())
151203 }
152204
@@ -211,9 +263,18 @@ func effectiveIdleGrace(config *brokerconfig.Config, explicit time.Duration) (ti
211263 // supervisor open from construction until the listener and its accept drain are
212264 // ready, so the idle timer can never fire during setup.
213265 func runBrokerServe(ctx context.Context, options brokerServeOptions, deps brokerServeDeps) (retErr error) {
214- layout, err := offlineLayout(options.offlineRoot)
215- if err != nil {
216- return err
266+ var layout brokerconfig.Layout
267+ var configPath string
268+ var err error
269+ if options.production {
270+ layout = productionBrokerLayout()
271+ configPath = productionBrokerConfigPath()
272+ } else {
273+ layout, err = offlineLayout(options.offlineRoot)
274+ if err != nil {
275+ return err
276+ }
277+ configPath = filepath.Join(layout.Root, brokerconfig.ConfigFileName)
217278 }
218279 for _, dir := range []string{layout.Root, layout.Runtime, layout.State, layout.Log} {
219280 if err := safedir.EnsurePrivate(dir); err != nil {
@@ -227,7 +288,19 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
227288 if err := layout.VerifyCreated(); err != nil {
228289 return fmt.Errorf("vev: verify broker sandbox paths: %w", err)
229290 }
230- config, err := brokerconfig.Load(layout)
291+ var config *brokerconfig.Config
292+ if options.production {
293+ // The broker is allowed to precede the daemon. Identity is therefore an
294+ // optional existing authority here; acquisition binds it after an
295+ // authenticated StartIfNeeded connection when the daemon is first born.
296+ identity, loadErr := daemonidentity.Load(platform.StateDir())
297+ if loadErr != nil && !errors.Is(loadErr, os.ErrNotExist) {
298+ return fmt.Errorf("vev: load daemon authority: %w", loadErr)
299+ }
300+ config, err = brokerconfig.LoadProduction(layout, configPath, identity, localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir()))
301+ } else {
302+ config, err = brokerconfig.LoadPath(layout, configPath)
303+ }
231304 if err != nil {
232305 return err
233306 }
@@ -247,7 +320,22 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
247320 }
248321 defer func() { retErr = errors.Join(retErr, logCloser.Close()) }()
249322
250- store, err := brokerstore.Open(brokerstore.Options{Dir: layout.State})
323+ if options.production {
324+ // The durable route schema intentionally delegates SSH trust to OpenSSH.
325+ // Refuse nonrepresentable legacy overrides rather than silently dropping
326+ // security inputs during the one-time production import.
327+ for _, endpoint := range config.Endpoints() {
328+ registration, _ := config.Registration(endpoint)
329+ if registration.Route.KnownHostsFile() != "" || registration.Route.ConnectTimeout() != 0 {
330+ return errors.New("vev: production broker routes require SSH trust and timeouts in OpenSSH configuration")
331+ }
332+ }
333+ }
334+ initialHosts, err := configuredBrokerHosts(config)
335+ if err != nil {
336+ return err
337+ }
338+ store, err := brokerstore.Open(brokerstore.Options{Dir: layout.State, InitialHosts: initialHosts, InitialImportProvided: true})
251339 if err != nil {
252340 return fmt.Errorf("vev: open broker sandbox store: %w", err)
253341 }
@@ -283,36 +371,59 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
283371 }
284372 defer startupLease.Release()
285373
286- registryConfig, err := offlineRegistryConfig(config, log)
374+ // The sandbox retains its explicit route/trust fixture contract. Production
375+ // imports membership once, then uses only durable remote authority.
376+ dynamicRoutes := &brokerRoutes{local: config.Resolver()}
377+ var resolver ports.BrokerRouteAuthority = config.Resolver()
378+ connector, err := brokerMuxConnector(config, log)
379+ if options.production {
380+ resolver = dynamicRoutes
381+ connector, err = brokerDynamicMuxConnector(config, dynamicRoutes, log)
382+ }
287383 if err != nil {
288384 return err
289385 }
290- connector, err := brokerMuxConnector(config, log)
386+ var localIdentityLoaders []localIdentityLoader
387+ if options.production {
388+ localIdentityLoaders = append(localIdentityLoaders, func() (ports.BrokerDaemonIdentity, error) {
389+ identity, loadErr := daemonidentity.Load(platform.StateDir())
390+ if errors.Is(loadErr, os.ErrNotExist) {
391+ return "", nil
392+ }
393+ return identity, loadErr
394+ })
395+ }
396+ registryConfig, err := offlineRegistryConfig(config, epoch, connector, localIdentityLoaders...)
291397 if err != nil {
292398 return err
293399 }
294- resolver := config.Resolver()
295400 var remoteProbe ports.BrokerHostProbe
296- if len(config.Endpoints()) != 0 {
297- remoteProbe = &brokerRemoteProbe{
298- epoch: epoch, resolver: resolver, connector: connector,
299- policy: func(endpoint string) (ports.BrokerPolicy, bool) {
300- registration, ok := config.Registration(endpoint)
301- return registration.Policy, ok
302- },
401+ var concreteRemoteProbe *brokerRemoteProbe
402+ if options.production || len(config.Endpoints()) != 0 {
403+ concreteRemoteProbe = &brokerRemoteProbe{
404+ epoch: epoch, routes: resolver, connector: connector,
303405 }
406+ remoteProbe = concreteRemoteProbe
304407 }
305408 registry, err := broker.NewRegistryWithConfig(epoch, store, remoteProbe, clk, log, registryConfig)
306409 if err != nil {
307410 return err
308411 }
309- // Registration order is shutdown order reversed: registering the registry
310- // first, then the pool, then the listener drains them listener -> pool ->
311- // registry.
412+ dynamicRoutes.hosts = registry
413+ binder := ports.BrokerIdentityBinder(registry)
414+ if options.production {
415+ binder = productionIdentityBinder{delegate: registry, stateDir: platform.StateDir(), policy: localDaemonPolicy()}
416+ }
417+ if concreteRemoteProbe != nil {
418+ concreteRemoteProbe.binder = binder
419+ concreteRemoteProbe.hosts = registry
420+ }
421+ // RegisterRunner starts immediately: finish the cyclic composition before
422+ // publishing it to any goroutine. Shutdown remains listener -> pool -> registry.
312423 if err := supervisor.RegisterRunner("registry", registry); err != nil {
313424 return err
314425 }
315- pool, err := broker.NewPool(epoch, resolver, connector, clk, brokerServePoolLimits)
426+ pool, err := broker.NewPool(epoch, resolver, binder, connector, clk, brokerServePoolLimits)
316427 if err != nil {
317428 return err
318429 }
@@ -339,7 +450,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
339450 go drainBrokerAccept(listener, log, started, drained, failed)
340451 <-started
341452 startupLease.Release()
342- log.Info("broker_offline_ready", "socket", socketPath, "endpoints", len(config.Endpoints()))
453+ log.Info("broker_ready", "socket", socketPath, "endpoints", len(config.Endpoints()), "production", options.production)
343454 if deps.onReady != nil {
344455 deps.onReady(socketPath)
345456 }
@@ -358,7 +469,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
358469 closeErr := supervisor.Close()
359470 supervisorClosed = true
360471 <-drained
361- log.Info("broker_offline_stopped")
472+ log.Info("broker_stopped", "production", options.production)
362473 return closeErr
363474 }
364475
@@ -372,18 +483,74 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
372483 // is never logged; any other terminal accept failure is unexpected: it is logged
373484 // as an error and reported on failed so the caller commits shutdown instead of
374485 // leaving a bound broker that can no longer admit work.
375-func offlineRegistryConfig(config *brokerconfig.Config, log *slog.Logger) (broker.RegistryConfig, error) {
486+// configuredBrokerHosts converts the one-time configuration import into store
487+// input. brokerstore consumes it only while creating its first manifest; an
488+// existing (including deliberately empty) membership is never reseeded.
489+func configuredBrokerHosts(config *brokerconfig.Config) ([]ports.BrokerHostRecord, error) {
490+ records := make([]ports.BrokerHostRecord, 0, len(config.Endpoints()))
491+ for _, endpoint := range config.Endpoints() {
492+ registration, ok := config.Registration(endpoint)
493+ if !ok {
494+ return nil, fmt.Errorf("vev: missing configured broker registration %q", endpoint)
495+ }
496+ route, err := durableBrokerRoute(registration.Route)
497+ if err != nil {
498+ return nil, fmt.Errorf("vev: import configured broker host %q: %w", endpoint, err)
499+ }
500+ records = append(records, ports.BrokerHostRecord{Registration: registration.Registration, Pinned: true, Policy: registration.Policy, Identity: registration.Identity, Route: route})
501+ }
502+ return records, nil
503+}
504+
505+func durableBrokerRoute(route brokerconfig.Route) (ports.BrokerRouteSpec, error) {
506+ spec := ports.BrokerRouteSpec{Path: route.Path(), Target: route.Target(), Argv: route.Argv()}
507+ switch route.Kind() {
508+ case brokerconfig.RouteUnix:
509+ spec.Kind = ports.BrokerRouteUnix
510+ case brokerconfig.RouteSSHStdio:
511+ spec.Kind = ports.BrokerRouteSSHStdio
512+ case brokerconfig.RouteSSHQUIC:
513+ spec.Kind = ports.BrokerRouteSSHQUIC
514+ default:
515+ return ports.BrokerRouteSpec{}, errors.New("unknown configured broker route kind")
516+ }
517+ if err := spec.Validate(); err != nil {
518+ return ports.BrokerRouteSpec{}, err
519+ }
520+ return spec, nil
521+}
522+
523+// offlineRegistryConfig selects the registry's observation producers from one
524+// immutable configuration. A configuration that provisions a local binding
525+// always composes the broker-owned local producer over the broker's already
526+// owned endpoint connector, so the probe is dynamic: it derives its authority
527+// from the local route, the local daemon policy, and an identity loader that is
528+// re-read on every attempt, so the broker may precede the daemon and start
529+// observing the moment the daemon first publishes its identity. Such a
530+// composition is mutable: the broker owns host membership for its whole run,
531+// including a zero-host membership, so a caller can add and remove hosts.
532+//
533+// A configuration that provisions no local binding is an explicitly configured
534+// fixture: it is deliberately left observation-disabled, so a read-only
535+// snapshot owner issues no probes, arms no timers, and never reconciles. The
536+// disabled fixture's membership mode follows the endpoints it was provisioned
537+// with, exactly as before.
538+func offlineRegistryConfig(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (broker.RegistryConfig, error) {
376539 if config == nil {
377540 return broker.RegistryConfig{}, errors.New("vev: offline registry requires configuration")
378541 }
379542 if _, ok := config.LocalBinding(); !ok {
380- return broker.RegistryConfig{ObservationDisabled: true}, nil
543+ mode := broker.MembershipImmutable
544+ if len(config.Endpoints()) != 0 {
545+ mode = broker.MembershipMutable
546+ }
547+ return broker.RegistryConfig{ObservationDisabled: true, MembershipMode: mode}, nil
381548 }
382- local, err := brokerLocalObservation(config, log)
549+ local, err := brokerLocalObservation(config, epoch, connector, loaders...)
383550 if err != nil {
384551 return broker.RegistryConfig{}, err
385552 }
386- return broker.RegistryConfig{Local: local}, nil
553+ return broker.RegistryConfig{Local: local, MembershipMode: broker.MembershipMutable}, nil
387554 }
388555
389556 func drainBrokerAccept(listener ports.BrokerListener, log *slog.Logger, started chan<- struct{}, drained chan<- struct{}, failed chan<- struct{}) {
M internal/app/broker_offline_client.go +75 -118
@@ -7,12 +7,10 @@ import (
77 "log/slog"
88 "os"
99 "strings"
10- "sync"
1110
1211 "github.com/bnema/vev/internal/adapters/brokeripc"
1312 "github.com/bnema/vev/internal/adapters/clock"
1413 "github.com/bnema/vev/internal/adapters/term"
15- "github.com/bnema/vev/internal/adapters/uidriver"
1614 "github.com/bnema/vev/internal/adapters/uiterm"
1715 "github.com/bnema/vev/internal/domain"
1816 "github.com/bnema/vev/internal/ports"
@@ -32,11 +30,14 @@ const (
3230 type brokerClientOptions struct {
3331 offlineRoot string
3432 harness string
33+ // picker starts the harness with no initial navigation at all, exactly like
34+ // `--ui-driver --picker`: the client presents the picker and opens nothing.
35+ picker bool
3536 }
3637
3738 func parseBrokerClientArgs(args []string) (command, error) {
3839 options := brokerClientOptions{harness: offlineClientTerminal}
39- var rootSet, harnessSet bool
40+ var rootSet, harnessSet, pickerSet bool
4041 for index := 0; index < len(args); {
4142 switch args[index] {
4243 case "--offline-root":
@@ -60,6 +61,12 @@ func parseBrokerClientArgs(args []string) (command, error) {
6061 return command{}, usagef("`--harness` requires terminal or ui-driver")
6162 }
6263 index += 2
64+ case "--picker":
65+ if pickerSet {
66+ return command{}, usagef("`%s` received duplicate --picker", brokerClientCommand)
67+ }
68+ options.picker, pickerSet = true, true
69+ index++
6370 default:
6471 if strings.HasPrefix(args[index], "-") {
6572 return command{}, usagef("unknown flag %q for `%s`", args[index], brokerClientCommand)
@@ -70,6 +77,9 @@ func parseBrokerClientArgs(args []string) (command, error) {
7077 if !rootSet {
7178 return command{}, usagef("`%s` requires --offline-root", brokerClientCommand)
7279 }
80+ if pickerSet && options.harness != offlineClientUIDriver {
81+ return command{}, usagef("`%s` --picker requires --harness ui-driver", brokerClientCommand)
82+ }
7383 return command{kind: kindBrokerClient, brokerClient: options}, nil
7484 }
7585
@@ -89,136 +99,83 @@ func runBrokerClientCommand(ctx context.Context, options brokerClientOptions) er
8999 }
90100 defer terminal.Close()
91101 ui := client.NewUI(terminal, clock.New())
92- return runOfflineUIDriver(ctx, brokeripc.SocketPath(layout.Runtime), terminal, ui, log, &stdioStream{reader: os.Stdin, writer: os.Stdout})
102+ navigation := localEphemeralNavigation()
103+ if options.picker {
104+ navigation = client.InitialNavigation{}
105+ }
106+ return runOfflineUIDriverClient(ctx, offlineUIDriverHarness{
107+ socket: brokeripc.SocketPath(layout.Runtime),
108+ terminal: terminal,
109+ ui: ui,
110+ log: log,
111+ navigation: navigation,
112+ stream: &stdioStream{reader: os.Stdin, writer: os.Stdout},
113+ })
93114 default:
94115 return usagef("unknown offline client harness %q", options.harness)
95116 }
96117 }
97118
98-// runOfflineClient is the common offline terminal composition used by the
99-// physical terminal, headless UI driver, and browser terminal harnesses. The
100-// connector is the real broker IPC adapter and InitialNavigation opens a real
101-// broker logical stream; no direct daemon dialer is present.
119+// runOfflineClient is the sandbox terminal harness over the shared broker
120+// client composition. It supplies the real broker IPC connector for one
121+// offline root and the no-argument ephemeral local creation, then delegates the
122+// whole run to runBrokerClient: the sandbox adds no picker, supervisor, dialer,
123+// or stream of its own.
102124 func runOfflineClient(ctx context.Context, socket string, terminal ports.Terminal, ui *client.UI, log *slog.Logger, onState func(client.State), onFailure func(error)) error {
103125 if terminal == nil {
104126 return errors.New("vev: offline client requires a terminal")
105127 }
106- clk := clock.New()
107- picker := client.NewPicker(clk, 0)
108- navigation := client.InitialNavigationCreateEphemeral
109- supervisor, err := client.NewSupervisor(client.SupervisorConfig{
110- Connector: brokeripc.NewConnector(socket, brokeripc.Config{}),
111- Terminal: terminal,
112- Clock: clk,
113- UI: ui,
114- Picker: picker,
115- InitialNavigation: &navigation,
116- AttachmentEnvironment: client.AttachmentEnvironment{
117- TermEnv: os.Getenv("TERM"), Cwd: currentWorkingDirectory(), TrueColor: client.DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"), os.Environ()),
118- },
119- Render: offlineClientRender(terminal, picker, onState),
120- Notify: func(_ client.State, err error) {
121- if onFailure != nil {
122- onFailure(err)
123- }
124- },
125- })
126- if err != nil {
127- return err
128- }
129128 if log != nil {
130129 log.Debug("broker_offline_client", "socket", socket)
131130 }
132- return supervisor.Run(ctx)
133-}
134-
135-// offlineClientRender is the production offline render callback. Every state
136-// reaches the optional observer; the terminal is written only while the shared
137-// client.PickerPresentation fence admits the presentation, which is exactly
138-// PresentPicker. That is what lets a resize invalidation repaint the picker at
139-// its current geometry while refusing the pre-attachment connecting transition,
140-// whose admitted foreground already owns the same terminal writer.
141-//
142-// ResizeEvents has exactly one consumer for the whole supervisor run: its
143-// attachment geometry collector. Picker rendering samples Geometry here at
144-// render time instead of competing for that single event channel, so a repaint
145-// always observes the latest size.
146-//
147-// The fence is exactly PickerPresentation. An attached foreground owns the
148-// terminal writer and a terminating process is leaving it, so neither is ever
149-// painted over from here.
150-func offlineClientRender(terminal ports.Terminal, picker *client.Picker, onState func(client.State)) func(client.State) {
151- var writer sync.Mutex
152- return func(state client.State) {
153- if onState != nil {
154- onState(state)
155- }
156- if !client.PickerPresentation(state) {
157- return
158- }
159- geometry, err := terminal.Geometry()
160- if err != nil {
161- return
162- }
163- frame := picker.Render(geometry.Size)
164- notice := picker.RenderNotice(geometry.Size)
165- if len(frame) == 0 && len(notice) == 0 {
166- return
167- }
168- writer.Lock()
169- defer writer.Unlock()
170- if len(frame) != 0 {
171- _, _ = terminal.Out().Write(frame)
172- }
173- if len(notice) != 0 {
174- _, _ = terminal.Out().Write(notice)
175- }
176- _ = terminal.Flush()
177- }
131+ return runBrokerClient(ctx, brokerClientConfig{
132+ Connector: brokeripc.NewConnector(socket, brokeripc.Config{}),
133+ Terminal: terminal,
134+ UI: ui,
135+ // No-argument composition creates one ephemeral local session through
136+ // the closed initial-navigation union; there is no special attach path.
137+ InitialNavigation: localEphemeralNavigation(),
138+ AttachmentEnvironment: terminalAttachmentEnvironment(),
139+ SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI},
140+ OnState: onState,
141+ OnFailure: onFailure,
142+ })
178143 }
179144
180-func currentWorkingDirectory() string {
181- cwd, err := os.Getwd()
182- if err != nil {
183- return ""
184- }
185- return cwd
145+// offlineUIDriverHarness is the sandbox harness input: one explicitly supplied
146+// offline broker socket, the virtual terminal and UI service built over it, the
147+// one-shot navigation the harness was asked to perform, and the stream the JSONL
148+// protocol is served on. The CLI's `--picker` selects the zero picker intent; the
149+// default is the same no-argument local ephemeral creation every frontend uses.
150+type offlineUIDriverHarness struct {
151+ socket string
152+ terminal *uiterm.Terminal
153+ ui *client.UI
154+ log *slog.Logger
155+ navigation client.InitialNavigation
156+ resolver client.InitialNavigationResolver
157+ stream io.ReadWriteCloser
186158 }
187159
188-func runOfflineUIDriver(ctx context.Context, socket string, terminal *uiterm.Terminal, ui *client.UI, log *slog.Logger, stream io.ReadWriteCloser) error {
189- runCtx, cancel := context.WithCancel(ctx)
190- defer cancel()
191- runnerDone := make(chan error, 1)
192- states := make(chan client.State, 1)
193- go func() {
194- runnerDone <- runOfflineClient(runCtx, socket, terminal, ui, log, func(state client.State) {
195- select {
196- case states <- state:
197- default:
198- }
199- }, nil)
200- }()
201-
202- select {
203- case <-states:
204- case err := <-runnerDone:
205- return err
206- case <-runCtx.Done():
207- return runCtx.Err()
208- }
209- // Ready advertises the currently actionable UI generation. Before the first
210- // attachment there is no actionable generation; attached snapshots publish
211- // the non-zero generation clients must fence actions against.
212- ready := uidriver.Ready{Attachment: ui.Handle(), Control: true, Status: ports.UIStatusReconnecting}
213- server := uidriver.New(ui, clock.New())
214- serveDone := make(chan error, 1)
215- go func() { serveDone <- server.Serve(runCtx, stream, ready) }()
216- select {
217- case err := <-serveDone:
218- cancel()
219- return errors.Join(err, ignoreContextCancellation(<-runnerDone))
220- case err := <-runnerDone:
221- cancel()
222- return errors.Join(err, ignoreContextCancellation(<-serveDone))
160+// runOfflineUIDriverClient composes the sandbox UI-driver harness over the same
161+// runUIDriverClient path production uses. Its connector is the real broker IPC
162+// connector of one explicitly supplied offline root: the sandbox adds no
163+// production fallback, starts no broker of its own, and never reaches the
164+// production XDG runtime or state. No session, endpoint, or process is
165+// provisioned here: the harness reaches sessions only through the broker of its
166+// own root, exactly like the production driver.
167+func runOfflineUIDriverClient(ctx context.Context, harness offlineUIDriverHarness) error {
168+ if harness.log != nil {
169+ harness.log.Debug("broker_offline_ui_driver", "socket", harness.socket)
223170 }
171+ return runUIDriverClient(ctx, brokerClientConfig{
172+ Connector: brokeripc.NewConnector(harness.socket, brokeripc.Config{}),
173+ Terminal: harness.terminal,
174+ Clock: clock.New(),
175+ UI: harness.ui,
176+ InitialNavigation: harness.navigation,
177+ ResolveInitialNavigation: harness.resolver,
178+ AttachmentEnvironment: terminalAttachmentEnvironment(),
179+ SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI},
180+ }, harness.stream)
224181 }
M internal/app/broker_offline_client_render_test.go +68 -3
@@ -152,7 +152,7 @@ func TestOfflineClientRenderRefusesNonPickerPresentations(t *testing.T) {
152152 picker := offlineRenderPicker(t)
153153
154154 observed := make(chan client.State, 8)
155- render := offlineClientRender(terminal, picker, func(state client.State) {
155+ render := brokerClientRender(terminal, picker, func(state client.State) {
156156 select {
157157 case observed <- state:
158158 default:
@@ -192,7 +192,7 @@ func TestOfflineClientRenderFenceIsPickerPresentation(t *testing.T) {
192192 for _, state := range states {
193193 t.Run(state.Presentation.String(), func(t *testing.T) {
194194 terminal := newOfflineRenderTerminal(small)
195- render := offlineClientRender(terminal, offlineRenderPicker(t), nil)
195+ render := brokerClientRender(terminal, offlineRenderPicker(t), nil)
196196
197197 render(state)
198198
@@ -218,7 +218,7 @@ func TestOfflineClientRenderPaintsPickerResize(t *testing.T) {
218218
219219 terminal := newOfflineRenderTerminal(small)
220220 picker := offlineRenderPicker(t)
221- render := offlineClientRender(terminal, picker, nil)
221+ render := brokerClientRender(terminal, picker, nil)
222222
223223 render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})
224224 initial, flushes := terminal.written()
@@ -234,3 +234,68 @@ func TestOfflineClientRenderPaintsPickerResize(t *testing.T) {
234234 require.Contains(t, strings.TrimPrefix(resized, initial), "offline-render-session", "the appended repaint is the picker frame at the new size")
235235 require.Equal(t, 2, flushes)
236236 }
237+
238+// transactionRecordingTerminal is a composition terminal that also records the
239+// UI output transactions the picker paint commits, so a test can prove the
240+// picker frame and its published context are one unattached state.
241+type transactionRecordingTerminal struct {
242+ *offlineRenderTerminal
243+ mu sync.Mutex
244+ contexts []ports.UIContext
245+ beginDepth int
246+ endSuccesses []bool
247+}
248+
249+func newTransactionRecordingTerminal(geometry domain.Geometry) *transactionRecordingTerminal {
250+ return &transactionRecordingTerminal{offlineRenderTerminal: newOfflineRenderTerminal(geometry)}
251+}
252+
253+func (t *transactionRecordingTerminal) BeginOutput(context ports.UIContext) {
254+ t.mu.Lock()
255+ defer t.mu.Unlock()
256+ t.contexts = append(t.contexts, context)
257+ t.beginDepth++
258+}
259+
260+func (t *transactionRecordingTerminal) EndOutput(success bool) {
261+ t.mu.Lock()
262+ defer t.mu.Unlock()
263+ t.beginDepth--
264+ t.endSuccesses = append(t.endSuccesses, success)
265+}
266+
267+func (t *transactionRecordingTerminal) PublishContext(context ports.UIContext) error {
268+ t.mu.Lock()
269+ defer t.mu.Unlock()
270+ t.contexts = append(t.contexts, context)
271+ return nil
272+}
273+
274+func (t *transactionRecordingTerminal) published() []ports.UIContext {
275+ t.mu.Lock()
276+ defer t.mu.Unlock()
277+ return append([]ports.UIContext(nil), t.contexts...)
278+}
279+
280+// TestOfflineClientPickerPaintCommitsPickerTransaction pins that the picker frame
281+// is committed through the terminal's UI output transaction under the Picker
282+// presentation: the published context carries the run's handle and status with
283+// no session identity and no actionable generation, and the transaction
284+// succeeds only after the frame was written and flushed.
285+func TestOfflineClientPickerPaintCommitsPickerTransaction(t *testing.T) {
286+ terminal := newTransactionRecordingTerminal(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
287+ render := brokerClientRender(terminal, offlineRenderPicker(t), nil)
288+
289+ render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})
290+ written, _ := terminal.written()
291+ require.NotEmpty(t, written, "the picker paint writes the frame")
292+ published := terminal.published()
293+ require.NotEmpty(t, published, "the picker paint commits a context through the UI transaction")
294+ for _, context := range published {
295+ require.Equal(t, ports.UIStatusPicker, context.Status)
296+ require.Zero(t, context.Generation, "a Picker context never carries an actionable generation")
297+ require.Equal(t, ports.UIContext{Status: ports.UIStatusPicker}, context, "a Picker context carries no session metadata")
298+ }
299+ require.Equal(t, 0, terminal.beginDepth, "the transaction is balanced")
300+ require.Equal(t, []bool{true}, terminal.endSuccesses, "the frame committed after write and flush")
301+}
M internal/app/broker_offline_client_test.go +160 -73
@@ -4,7 +4,8 @@ package app
44
55 import (
66 "context"
7- "encoding/json"
7+ "crypto/rand"
8+ "errors"
89 "io"
910 "net/http"
1011 "net/http/httptest"
@@ -24,13 +25,15 @@ import (
2425 "github.com/bnema/vev/internal/adapters/daemonmux"
2526 "github.com/bnema/vev/internal/adapters/ipc"
2627 "github.com/bnema/vev/internal/adapters/pty"
28+ "github.com/bnema/vev/internal/adapters/snapshot"
2729 "github.com/bnema/vev/internal/adapters/uiterm"
2830 "github.com/bnema/vev/internal/adapters/webterm"
2931 "github.com/bnema/vev/internal/domain"
32+ "github.com/bnema/vev/internal/persist"
3033 "github.com/bnema/vev/internal/ports"
3134 "github.com/bnema/vev/internal/protocol"
32- "github.com/bnema/vev/internal/usecase/client"
3335 "github.com/bnema/vev/internal/usecase/daemon"
36+ "github.com/bnema/vev/internal/usecase/recovery"
3437 "github.com/bnema/vev/pkg/safedir"
3538 )
3639
@@ -45,9 +48,34 @@ type offlineClientFixture struct {
4548 cancel context.CancelFunc
4649 prodRuntime string
4750 prodState string
51+ // catalogue is the daemon's durable session catalogue when the fixture was
52+ // started with persistence; nil otherwise.
53+ catalogue *persist.Persister
4854 }
4955
5056 func startOfflineClientFixture(t *testing.T) offlineClientFixture {
57+ t.Helper()
58+ // The default sandbox shell prints one marker and then parks on read, so a
59+ // client proves it reached a session without depending on shell echo of
60+ // injected input.
61+ return startOfflineClientFixtureWithShell(t, "/bin/sh", []string{"-c", "sleep 0.1; printf offline-ready; trap : TERM; while :; do read line || exit; done"})
62+}
63+
64+// startOfflineClientFixtureWithShell starts the same private broker sandbox with
65+// an explicitly supplied daemon shell, so a browser test can drive real session
66+// content and real session actions (echo and command output) through the
67+// ordinary client path instead of a parked shell.
68+func startOfflineClientFixtureWithShell(t *testing.T, shell string, shellArgs []string) offlineClientFixture {
69+ t.Helper()
70+ return startPersistentOfflineClientFixture(t, shell, shellArgs, false)
71+}
72+
73+// startPersistentOfflineClientFixture starts the same private broker sandbox but
74+// additionally binds the daemon to real session persistence under the sandbox
75+// state directory. It exists so a test can prove which lifecycle state the
76+// daemon actually persists: a named session appears in the durable catalogue,
77+// while an ephemeral one is never written.
78+func startPersistentOfflineClientFixture(t *testing.T, shell string, shellArgs []string, persistEnabled bool) offlineClientFixture {
5179 t.Helper()
5280 root, prodRuntime, prodState := isolateSandboxEnv(t)
5381 require.NoError(t, os.MkdirAll(root, 0o700))
@@ -96,7 +124,22 @@ func startOfflineClientFixture(t *testing.T) offlineClientFixture {
96124 }()
97125 streamSeen := make(chan struct{}, 16)
98126 counting := &countingServerListener{ServerListener: aggregate, seen: streamSeen}
99- d := daemon.New(pty.NewFactory(), clock.New(), discardLog(), daemon.WithShell("/bin/sh", []string{"-c", "sleep 0.1; printf offline-ready; trap : TERM; while :; do read line || exit; done"}))
127+ daemonOpts := []daemon.Option{daemon.WithShell(shell, shellArgs)}
128+ var fixtureCatalogue *persist.Persister
129+ if persistEnabled {
130+ stateDir := filepath.Join(root, "state")
131+ opened, err := persist.OpenOrCreate(stateDir)
132+ require.NoError(t, err)
133+ repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
134+ coordinator := recovery.NewCoordinator(opened.Catalogue, repository, rand.Reader)
135+ daemonOpts = append(daemonOpts,
136+ daemon.WithCatalogue(opened.Catalogue, opened.Records),
137+ daemon.WithSnapshotRepository(repository),
138+ daemon.WithRecoveryCoordinator(coordinator),
139+ )
140+ fixtureCatalogue = opened.Catalogue
141+ }
142+ d := daemon.New(pty.NewFactory(), clock.New(), discardLog(), daemonOpts...)
100143 go func() { _ = d.Serve(ctx, counting) }()
101144
102145 // The client picker and registry must share wall-clock freshness semantics:
@@ -112,6 +155,28 @@ func startOfflineClientFixture(t *testing.T) offlineClientFixture {
112155 case <-time.After(brokerTestWait):
113156 t.Fatal("offline broker did not become ready")
114157 }
158+ // The sandbox broker becomes socket-ready before its first local daemon
159+ // observation is committed. Product clients start from a committed local
160+ // authority; wait for the fixture to provide the same contract so one-shot
161+ // initial navigation is not consumed against an artificial empty catalogue.
162+ probe, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx)
163+ require.NoError(t, err)
164+ subscription, err := probe.Subscribe()
165+ require.NoError(t, err)
166+ require.Eventually(t, func() bool {
167+ for _, observation := range probe.Snapshot().Daemons {
168+ if observation.Local {
169+ return true
170+ }
171+ }
172+ select {
173+ case <-subscription.Changed():
174+ default:
175+ }
176+ return false
177+ }, brokerTestWait, 5*time.Millisecond, "offline broker never committed its local daemon authority")
178+ subscription.Close()
179+ require.NoError(t, probe.Close())
115180 t.Cleanup(func() {
116181 cancel()
117182 _ = rawListener.Close()
@@ -137,7 +202,12 @@ func startOfflineClientFixture(t *testing.T) offlineClientFixture {
137202 defer physicalMu.Unlock()
138203 return len(physicalConnections)
139204 }
140- return offlineClientFixture{root: root, socket: socket, streams: streamSeen, physical: physicalSeen, physicalCount: physicalCount, policy: policy, losePhysical: losePhysical, cancel: cancel, prodRuntime: prodRuntime, prodState: prodState}
205+ return offlineClientFixture{
206+ root: root, socket: socket, streams: streamSeen, physical: physicalSeen,
207+ physicalCount: physicalCount, policy: policy, losePhysical: losePhysical,
208+ cancel: cancel, prodRuntime: prodRuntime, prodState: prodState,
209+ catalogue: fixtureCatalogue,
210+ }
141211 }
142212
143213 type countingServerListener struct {
@@ -234,85 +304,84 @@ func mustConnectivityOwner(t *testing.T, kind string) connectivityOwner {
234304 return owner
235305 }
236306
237-func TestOfflineClientTerminalReachesSessionThroughBrokerIPC(t *testing.T) {
238- fixture := startOfflineClientFixture(t)
239- ctx, cancel := context.WithCancel(t.Context())
240- terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "")
307+// TestOfflineEphemeralSessionIsNotPersistedViaBroker proves the daemon-owned
308+// broker fixture persists a named session but never an ephemeral one: the
309+// durable catalogue holds exactly the named record after both lifecycles, so
310+// the ephemeral session survives only while its daemon does.
311+func TestOfflineEphemeralSessionIsNotPersistedViaBroker(t *testing.T) {
312+ fixture := startPersistentOfflineClientFixture(t, "/bin/sh",
313+ []string{"-c", "sleep 0.1; printf ephemeral-ready; trap : TERM; while :; do read line || exit; done"}, true)
314+ require.NotNil(t, fixture.catalogue, "the persistent fixture must expose its durable catalogue")
315+ ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
316+ defer cancel()
317+
318+ service, err := brokeripc.NewConnector(fixture.socket, brokeripc.Config{}).Connect(ctx)
241319 require.NoError(t, err)
242- done := make(chan error, 1)
243- failures := make(chan error, 1)
244- go func() {
245- done <- runOfflineClient(ctx, fixture.socket, terminal, nil, discardLog(), nil, func(err error) {
246- select {
247- case failures <- err:
248- default:
249- }
250- })
251- }()
252- select {
253- case <-fixture.streams:
254- case err := <-failures:
255- t.Fatalf("offline client stream open failed: %v (%#v)", err, err)
256- case <-time.After(brokerTestWait):
257- t.Fatal("client did not reach a session through a broker logical stream")
320+ defer service.Close()
321+
322+ create := func(name string, admission ports.BrokerStreamAdmission, intent uint8) {
323+ t.Helper()
324+ streamID, err := service.NextStreamID()
325+ require.NoError(t, err)
326+ request := ports.BrokerOpenStreamRequest{
327+ Purpose: ports.BrokerStreamAttachment, Stream: streamID, Local: true,
328+ Policy: fixture.policy, Admission: admission, Name: name,
329+ StartMode: ports.BrokerDaemonStartIfNeeded,
330+ }
331+ stream, err := service.OpenStream(ctx, request)
332+ require.NoError(t, err)
333+ require.NoError(t, stream.SendClient(protocol.Hello{
334+ Version: protocol.Version, Intent: intent, Name: name,
335+ Size: domain.Size{Cols: 80, Rows: 24}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
336+ }))
337+ receiveOfflineWelcome(t, stream)
338+ require.NoError(t, stream.Close())
258339 }
259- awaitUITerminalMarker(t, terminal, "offline-ready")
260- cancel()
261- select {
262- case err := <-done:
263- require.NoError(t, ignoreContextCancellation(err))
264- case <-time.After(brokerTestWait):
265- t.Fatal("offline terminal client did not stop")
340+
341+ create("persisted", ports.BrokerAdmissionCreateNamed, protocol.IntentNew)
342+ create("", ports.BrokerAdmissionCreateEphemeral, protocol.IntentEphemeral)
343+
344+ // The daemon persists on its own cadence; the durable catalogue must settle
345+ // to exactly the named record and never gain the ephemeral one.
346+ require.Eventually(t, func() bool {
347+ records, err := fixture.catalogue.Records()
348+ require.NoError(t, err)
349+ return len(records) == 1 && records[0].Name == "persisted"
350+ }, brokerTestWait, brokerTestPollTick, "the durable catalogue must hold exactly the named session")
351+
352+ records, err := fixture.catalogue.Records()
353+ require.NoError(t, err)
354+ for _, record := range records {
355+ require.NotEmpty(t, record.Name, "an ephemeral session must never be persisted")
266356 }
267357 }
268358
269-func TestOfflineClientUIDriverReachesSessionThroughBrokerIPC(t *testing.T) {
359+func TestOfflineClientTerminalReachesSessionThroughBrokerIPC(t *testing.T) {
270360 fixture := startOfflineClientFixture(t)
271361 ctx, cancel := context.WithCancel(t.Context())
272- defer cancel()
273362 terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "")
274363 require.NoError(t, err)
275- ui := client.NewUI(terminal, clock.New())
276- serverEnd, clientEnd := io.Pipe()
277- writerReader, writerEnd := io.Pipe()
278- stream := &stdioStream{reader: serverEnd, writer: writerEnd}
279364 done := make(chan error, 1)
280- go func() { done <- runOfflineUIDriver(ctx, fixture.socket, terminal, ui, discardLog(), stream) }()
281- awaitLogicalStream(t, fixture.streams)
282- decoder := json.NewDecoder(writerReader)
283- readyResult := make(chan map[string]any, 1)
284- readyError := make(chan error, 1)
285365 go func() {
286- var ready map[string]any
287- if err := decoder.Decode(&ready); err != nil {
288- readyError <- err
289- return
290- }
291- readyResult <- ready
366+ done <- runOfflineClient(ctx, fixture.socket, terminal, nil, discardLog(), nil, nil)
292367 }()
293- select {
294- case ready := <-readyResult:
295- result, ok := ready["result"].(map[string]any)
296- require.True(t, ok)
297- require.Equal(t, true, result["control"], "autonomous supervisor binds UI actions")
298- require.Equal(t, string(ports.UIStatusReconnecting), result["status"], "ready precedes the attachment publication")
299- require.Equal(t, float64(0), result["generation"], "ready has no actionable generation before attachment publication")
300- case err := <-readyError:
301- t.Fatalf("decode UI-driver ready: %v", err)
302- case <-time.After(brokerTestWait):
303- t.Fatal("offline UI driver did not publish ready")
304- }
368+ awaitLogicalStream(t, fixture.streams)
305369 awaitUITerminalMarker(t, terminal, "offline-ready")
306- _ = clientEnd.Close()
307- _ = writerReader.Close()
308370 cancel()
309371 select {
310- case <-done:
372+ case err := <-done:
373+ require.NoError(t, ignoreContextCancellation(err))
311374 case <-time.After(brokerTestWait):
312- t.Fatal("offline UI driver did not stop")
375+ t.Fatal("offline terminal client did not stop")
313376 }
314377 }
315378
379+// The sandbox UI-driver harness is exercised by
380+// TestUIDriverSandboxHarnessUsesItsOwnConnector, TestUIDriverSandboxHarnessPickerOpensNothing,
381+// TestUIDriverClientKeepsSharedDaemonOnEOF, and the shared
382+// runUIDriverClient tests in ui_driver_client_test.go; the harness is the same
383+// runUIDriverClient call production uses, so it is not re-tested here.
384+
316385 func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) {
317386 fixture := startOfflineClientFixture(t)
318387 ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
@@ -324,7 +393,9 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) {
324393 connections := make([]ports.BrokerLogicalConnection, 3)
325394 welcomes := make([]protocol.Welcome, 3)
326395 for i := range connections {
327- request := ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamAttachment, Local: true, Policy: fixture.policy}
396+ streamID, err := service.NextStreamID()
397+ require.NoError(t, err)
398+ request := ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamAttachment, Stream: streamID, Local: true, Policy: fixture.policy, StartMode: ports.BrokerDaemonStartIfNeeded}
328399 if i == 0 {
329400 request.Admission, request.Name = ports.BrokerAdmissionCreateNamed, "shared"
330401 } else {
@@ -387,8 +458,7 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) {
387458 }
388459
389460 require.NoError(t, connections[0].SendClient(protocol.Detach{}))
390- receiveOfflineDetached(t, connections[0])
391- require.NoError(t, connections[0].Close())
461+ require.NoError(t, receiveOfflineEOF(t, connections[0]), "detach must end its logical stream with orderly EOF")
392462 for i := 1; i < len(connections); i++ {
393463 requestID := uint64(20 + i)
394464 require.NoError(t, connections[i].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: requestID, Attached: true, Slug: "next-tab"}))
@@ -410,7 +480,9 @@ func TestOfflineBrokerPhysicalLossSettlesEverySharedStream(t *testing.T) {
410480 var target protocol.ExactSessionTarget
411481 connections := make([]ports.BrokerLogicalConnection, 3)
412482 for i := range connections {
413- request := ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamAttachment, Local: true, Policy: fixture.policy}
483+ streamID, err := service.NextStreamID()
484+ require.NoError(t, err)
485+ request := ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamAttachment, Stream: streamID, Local: true, Policy: fixture.policy, StartMode: ports.BrokerDaemonStartIfNeeded}
414486 if i == 0 {
415487 request.Admission, request.Name = ports.BrokerAdmissionCreateNamed, "loss"
416488 } else {
@@ -469,12 +541,27 @@ func receiveOfflineCommandResult(t *testing.T, connection ports.BrokerLogicalCon
469541 return message.(protocol.CommandResult)
470542 }
471543
472-func receiveOfflineDetached(t *testing.T, connection ports.BrokerLogicalConnection) {
544+func receiveOfflineEOF(t *testing.T, connection ports.BrokerLogicalConnection) error {
473545 t.Helper()
474- receiveOfflineMessage(t, connection, "Detached", func(message protocol.ServerMessage) bool {
475- _, ok := message.(protocol.Detached)
476- return ok
477- })
546+ result := make(chan error, 1)
547+ go func() {
548+ for {
549+ _, err := connection.ReceiveServer()
550+ if err != nil {
551+ result <- err
552+ return
553+ }
554+ }
555+ }()
556+ select {
557+ case err := <-result:
558+ if errors.Is(err, io.EOF) {
559+ return nil
560+ }
561+ return err
562+ case <-time.After(brokerTestWait):
563+ return errors.New("timed out waiting for orderly EOF")
564+ }
478565 }
479566
480567 func receiveOfflineMessage(t *testing.T, connection ports.BrokerLogicalConnection, want string, match func(protocol.ServerMessage) bool) protocol.ServerMessage {
M internal/app/broker_offline_local.go +155 -72
@@ -4,7 +4,6 @@ import (
44 "context"
55 "errors"
66 "fmt"
7- "log/slog"
87
98 "github.com/bnema/vev/internal/adapters/brokerconfig"
109 "github.com/bnema/vev/internal/adapters/daemonmux"
@@ -13,140 +12,224 @@ import (
1312 "github.com/bnema/vev/internal/usecase/broker"
1413 )
1514
16-// Broker-owned local observation composition (Plan 001 P5.3a).
15+// Broker-owned local observation composition (Plan 001 P5.3a, P7.4b).
1716 //
1817 // The offline broker observes its own machine daemon through the same daemonmux
19-// carriage a local logical stream is dialed over. The probe completes only the
20-// physical preamble: it dials the provisioned local Unix route, negotiates
21-// identity, incarnation, and policy, and closes the carriage. It deliberately
22-// opens no logical stream, so observing the local daemon never attaches to a
23-// session, and it only ever dials an existing local Unix socket, so it never
24-// starts a stopped daemon. The composition is isolated from production state:
25-// it derives the probe from the sandbox configuration and never touches the
26-// production runtime or state directories.
18+// carriage a local logical stream is dialed over. The probe completes the
19+// authenticated physical preamble (identity, incarnation, and policy), opens
20+// exactly one logical BrokerStreamObservation stream, sends one typed
21+// remote-catalog control request, validates the catalogue the daemon answers,
22+// and closes the stream and carriage. It never sends a Hello, never claims a
23+// geometry, never requests an attachment admission, and never creates a
24+// session or a process, and it only ever dials an existing local Unix socket, so
25+// observing the local daemon never attaches to a session and never starts a
26+// stopped daemon.
27+//
28+// The composition is isolated from production state: it derives the probe from
29+// the sandbox configuration and never touches the production runtime or state
30+// directories. Remote and local observation share one semantic implementation
31+// (observeDaemonCatalogue) through the same daemonmux endpoint connector seam
32+// the broker already owns, so neither producer becomes a second transport
33+// owner and the two cannot drift apart.
2734 //
2835 // The hidden offline sandbox selects this producer whenever its strict config
2936 // provisions a local binding. Ordinary production composition does not select
3037 // it. The sandbox owns no remote membership, and probing remains no-spawn and
3138 // attachment-free even though it runs on the registry's observation cadence.
3239
33-// localCarrierDialer establishes one raw framed daemonmux carriage for the
34-// opaque route address the broker-owned local binding published. It is the same
35-// seam the pooled mux connector uses, so the probe can never select a transport
36-// or an address of its own.
37-type localCarrierDialer func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error)
40+// localCarrierDialer establishes one raw framed daemonmux carriage for one
41+// resolved dial target. It is the same seam the pooled mux connector uses, so
42+// the probe can never select a transport or an address of its own, and the
43+// target travels whole: the address selects the route while the start
44+// authorization, policy, and fence decide what the carriage owner may do.
45+type localCarrierDialer func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error)
3846
3947 // localRouteProbe is the broker-owned ports-independent local observation
4048 // probe. It holds the resolved local endpoint authority (identity, policy, and
41-// opaque address) and dials it exactly once per observation, then closes the
42-// carriage: no logical stream is ever opened and no daemon process is ever
43-// started.
49+// opaque address) and one daemonmux endpoint connector over the same dial and
50+// ceiling seam the pooled connector uses, and observes the daemon through it.
51+// Every probe dials one already authenticated local carriage, opens exactly one
52+// observation stream, and closes both; no logical attachment stream is ever
53+// opened and no daemon process is ever started.
54+type localIdentityLoader func() (ports.BrokerDaemonIdentity, error)
55+
4456 type localRouteProbe struct {
45- endpoint ports.BrokerResolvedEndpoint
46- dial localCarrierDialer
47- ceilings daemonmux.MuxCeilings
57+ epoch ports.BrokerEpoch
58+ route brokerconfig.Route
59+ policy ports.BrokerPolicy
60+ loadIdentity localIdentityLoader
61+ connector ports.BrokerEndpointConnector
4862 }
4963
5064 var _ broker.LocalProbe = (*localRouteProbe)(nil)
5165
52-// newLocalRouteProbe fences one broker-owned local binding into a probe. The
53-// binding's identity, policy, and route address must validate, the ceilings
54-// must be a valid advertisement, and a nil dial is refused: a probe that cannot
55-// establish a carriage must not exist.
56-func newLocalRouteProbe(binding brokerconfig.LocalBinding, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) {
66+// newLocalRouteProbe fences one broker-owned local binding into a probe over a
67+// dial seam. The binding's identity, policy, and route address must validate,
68+// the ceilings must be a valid advertisement, the epoch must be the broker
69+// process incarnation every local stream is scoped to, and a nil dial is
70+// refused: a probe that cannot establish a carriage must not exist. It builds
71+// its own endpoint connector; a composition that already owns the pooled
72+// connector passes it to newLocalRouteProbeWithConnector instead, so one broker
73+// process never holds two connector owners for the same route.
74+func newLocalRouteProbe(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) {
5775 if dial == nil {
5876 return nil, errors.New("vev: broker local probe requires a dialer")
5977 }
78+ connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), ceilings)
79+ if err != nil {
80+ return nil, fmt.Errorf("vev: broker local probe: %w", err)
81+ }
82+ return newLocalRouteProbeWithConnector(binding, epoch, connector)
83+}
84+
85+// newLocalRouteProbeWithConnector fences one broker-owned local binding into a
86+// probe over an existing connector. The connector is the same setup half a
87+// pooled physical connection uses, so the probe composes exactly the transport
88+// owner the broker already has and never becomes a second one. The binding's
89+// identity, policy, and route must validate and the epoch must be nonzero.
90+func newLocalRouteProbeWithConnector(binding brokerconfig.LocalBinding, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector) (*localRouteProbe, error) {
91+ return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, func() (ports.BrokerDaemonIdentity, error) {
92+ return binding.Identity, nil
93+ })
94+}
95+
96+func newDynamicLocalRouteProbe(route brokerconfig.Route, policy ports.BrokerPolicy, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loadIdentity localIdentityLoader) (*localRouteProbe, error) {
97+ if epoch == 0 {
98+ return nil, errors.New("vev: broker local probe requires a broker epoch")
99+ }
100+ if connector == nil {
101+ return nil, errors.New("vev: broker local probe requires a connector")
102+ }
103+ if loadIdentity == nil {
104+ return nil, errors.New("vev: broker local probe requires an identity loader")
105+ }
60106 // Probing must never spawn a process, so only a Unix route this process
61107 // dials directly is acceptable. brokerconfig refuses any other kind at load
62108 // time; this re-checks the exported binding a caller may have built itself,
63109 // because the dial seam routes an ssh address to a child process.
64- if !binding.Route.IsLocal() {
110+ if !route.IsLocal() {
65111 return nil, errors.New("vev: broker local probe requires a local route")
66112 }
67- if err := ceilings.Validate(); err != nil {
113+ if err := route.Validate(); err != nil {
68114 return nil, fmt.Errorf("vev: broker local probe: %w", err)
69115 }
70- endpoint := ports.BrokerResolvedEndpoint{Identity: binding.Identity, Policy: binding.Policy, Address: binding.Route.Address()}
71- if err := endpoint.Validate(); err != nil {
116+ if err := policy.Validate(); err != nil {
72117 return nil, fmt.Errorf("vev: broker local probe: %w", err)
73118 }
74- return &localRouteProbe{endpoint: endpoint, dial: dial, ceilings: ceilings}, nil
119+ return &localRouteProbe{epoch: epoch, route: route, policy: policy, loadIdentity: loadIdentity, connector: connector}, nil
75120 }
76121
77-// ProbeLocal observes the local daemon by completing the daemonmux physical
78-// preamble and closing the carriage. A successful preamble reports the only
79-// observed state the carriage exposes: the authenticated identity, the daemon
80-// incarnation, the protocol version the daemon's accepted policy negotiated,
81-// and reachability. Capabilities and the session catalogue are not carried by
82-// the physical preamble, so they stay unknown (zero) rather than being
83-// invented, and every failure reports an explicit availability with zero
84-// observed identity: a probe never manufactures identity, version, or
85-// inventory it did not observe.
122+// ProbeLocal observes the local daemon by authenticating the daemonmux physical
123+// preamble and then opening exactly one observation stream that asks the daemon
124+// for its own catalogue. A successful observation reports the authenticated
125+// identity, the daemon incarnation, the protocol version the catalogue schema
126+// negotiated, reachability, and the exact session inventory the daemon
127+// answered. Capabilities are not carried by the catalogue command, so they stay
128+// unknown (zero) rather than being invented, and every failure reports an
129+// explicit availability with zero observed identity: a probe never manufactures
130+// identity, version, or inventory it did not observe, and it never creates an
131+// attachment, a session, a process, or durable identity.
132+//
133+// The local daemon owns no configured membership, so the probe commits no
134+// durable identity binding: the binding's configured identity is the fence the
135+// physical preamble already verified.
86136 func (p *localRouteProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) {
87- if p == nil {
137+ if p == nil || p.connector == nil {
88138 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe is nil")
89139 }
90- raw, err := p.dial(ctx, p.endpoint.Address)
140+ identity, err := p.loadIdentity()
91141 if err != nil {
92- if raw != nil {
93- _ = raw.Close()
94- }
95142 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
96143 }
97- if raw == nil {
98- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe: nil carriage")
144+ if identity == "" {
145+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnknown}, nil
99146 }
100- bridge, err := daemonmux.NewPreambleCarrier(raw)
147+ endpoint := ports.BrokerDialTarget{
148+ Fence: ports.BrokerEndpointFence{Local: true}, Policy: p.policy, Address: p.route.Address(),
149+ StartMode: ports.BrokerDaemonExistingOnly,
150+ ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true},
151+ }
152+ if err := endpoint.Validate(); err != nil {
153+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, fmt.Errorf("vev: broker local probe: %w", err)
154+ }
155+ physical, err := p.connector.Connect(ctx, endpoint)
156+ if err != nil {
157+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
158+ }
159+ defer func() { _ = physical.Close() }()
160+ // The endpoint stays authoritative: re-verify the accepted authority even
161+ // though the connector already checked it, so a future connector change can
162+ // never weaken what the probe publishes.
163+ if physical.Identity() != endpoint.ExpectedIdentity.Identity || !physical.Policy().Compatible(endpoint.Policy) {
164+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe authenticated binding mismatch")
165+ }
166+ request, err := p.request()
101167 if err != nil {
102- _ = raw.Close()
103168 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
104169 }
105- // RunClientHandshake closes the carriage on every failure; on success the
106- // probe closes it here, because it has nothing further to send. The
107- // handshake never opens a logical stream.
108- result, err := daemonmux.RunClientHandshake(ctx, bridge, p.endpoint, p.ceilings)
109- _ = bridge.Close()
170+ observation, err := observeDaemonCatalogue(ctx, physical, request)
110171 if err != nil {
111172 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
112173 }
113- return ports.BrokerDaemonObservation{
114- Identity: result.Identity,
115- Incarnation: result.Incarnation,
116- ProtocolVersion: result.Policy.ProtocolVersion,
117- Availability: domain.RemoteAvailabilityReachable,
174+ return observation, nil
175+}
176+
177+// request builds the one observation open this probe performs: a local
178+// observation stream that may never start the daemon. Connection and stream
179+// identities are fresh per attempt so a replayed or duplicated open is refused
180+// by the peer's anti-replay admission window. The request carries no endpoint,
181+// no registration, no admission, no session name, no exact target, and no
182+// environment: the stream asks the daemon for its own catalogue and nothing
183+// else.
184+func (p *localRouteProbe) request() (ports.BrokerOpenStreamRequest, error) {
185+ connection, err := randomConnectionID()
186+ if err != nil {
187+ return ports.BrokerOpenStreamRequest{}, err
188+ }
189+ stream, err := randomNonzeroUint64()
190+ if err != nil {
191+ return ports.BrokerOpenStreamRequest{}, err
192+ }
193+ return ports.BrokerOpenStreamRequest{
194+ Epoch: p.epoch,
195+ Purpose: ports.BrokerStreamObservation,
196+ Local: true,
197+ Connection: connection,
198+ Stream: ports.BrokerStreamID(stream),
199+ Policy: p.policy,
200+ // Observation never starts the target: an ExistingOnly authorization is
201+ // what ports enforces for an observation stream, and a start-if-needed
202+ // one would be refused rather than silently widened into a spawn.
203+ StartMode: ports.BrokerDaemonExistingOnly,
118204 }, nil
119205 }
120206
121207 // brokerLocalProbe builds the broker-owned local probe over one validated
122-// offline configuration. The dialer looks the opaque address up in the same
123-// immutable configuration the pooled connector uses, so a probe can never reach
124-// an address the configuration did not provision.
125-func brokerLocalProbe(config *brokerconfig.Config, log *slog.Logger) (*localRouteProbe, error) {
208+// offline configuration and the broker's already owned endpoint connector, so
209+// the probe composes the exact transport owner the pool does instead of
210+// creating a second one.
211+func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (*localRouteProbe, error) {
126212 binding, ok := config.LocalBinding()
127213 if !ok {
128214 return nil, errors.New("vev: broker local probe requires a provisioned local binding")
129215 }
130- dial := func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) {
131- route, ok := config.RouteByAddress(address)
132- if !ok {
133- return nil, errors.New("vev: broker local probe: unknown route address")
134- }
135- return dialBrokerRoute(ctx, route, log)
216+ loader := localIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil })
217+ if len(loaders) != 0 {
218+ loader = loaders[0]
136219 }
137- return newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
220+ return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, loader)
138221 }
139222
140223 // brokerLocalObservation composes the registry's local producer from one
141224 // validated offline configuration: the configured display origin and policy
142225 // become the local entry's authority, and the probe supplies observed state
143226 // only. A configuration without a local binding provisions no local producer.
144-func brokerLocalObservation(config *brokerconfig.Config, log *slog.Logger) (*broker.LocalObservation, error) {
227+func brokerLocalObservation(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (*broker.LocalObservation, error) {
145228 binding, ok := config.LocalBinding()
146229 if !ok {
147230 return nil, errors.New("vev: broker local observation requires a provisioned local binding")
148231 }
149- probe, err := brokerLocalProbe(config, log)
232+ probe, err := brokerLocalProbe(config, epoch, connector, loaders...)
150233 if err != nil {
151234 return nil, err
152235 }
A internal/app/broker_offline_local_attach_test.go +327 -0
@@ -0,0 +1,327 @@
1+//go:build linux
2+
3+package app
4+
5+import (
6+ "context"
7+ "os"
8+ "path/filepath"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/adapters/brokerconfig"
16+ "github.com/bnema/vev/internal/adapters/clock"
17+ "github.com/bnema/vev/internal/adapters/daemonidentity"
18+ "github.com/bnema/vev/internal/adapters/daemonmux"
19+ "github.com/bnema/vev/internal/adapters/ipc"
20+ "github.com/bnema/vev/internal/adapters/pty"
21+ "github.com/bnema/vev/internal/domain"
22+ "github.com/bnema/vev/internal/ports"
23+ "github.com/bnema/vev/internal/protocol"
24+ "github.com/bnema/vev/internal/protocol/catalogue"
25+ "github.com/bnema/vev/internal/usecase/daemon"
26+)
27+
28+// P7.4b real-composition coverage for the broker-owned local probe.
29+//
30+// The scripted tests prove the probe's exact stream semantics. These tests run
31+// the same probe against a real daemon serving a real private Unix daemonmux
32+// carriage, so the authenticated physical preamble, the observation open, the
33+// typed remote-catalog command, and the catalogue validation all compose against
34+// production daemon code. They prove the observation is real: it creates no
35+// session, no process, and no durable identity, and the catalogue it publishes
36+// is exactly what makes an exact terminal attach resolvable.
37+
38+// realLocalDaemonFixture is a real daemon serving one private Unix daemonmux
39+// carriage, plus the sandbox state directory a probe must never write to.
40+type realLocalDaemonFixture struct {
41+ binding brokerconfig.LocalBinding
42+ route string
43+ stateDir string
44+ connector *daemonmux.EndpointConnector
45+ // nextStream allocates the strictly increasing logical stream identity each
46+ // helper attachment names, as the daemonmux engine requires within one
47+ // physical connection.
48+ nextStream ports.BrokerStreamID
49+}
50+
51+// startRealLocalDaemon starts one real daemon over a private local daemonmux
52+// carriage and returns the fixture the probe dials.
53+func startRealLocalDaemon(t *testing.T) *realLocalDaemonFixture {
54+ t.Helper()
55+ _, prodRuntime, prodState := isolateSandboxEnv(t)
56+ root := filepath.Join(shortTempDir(t, "vevl"), "sandbox")
57+ require.NoError(t, os.MkdirAll(root, 0o700))
58+ policy := brokerTestPolicy()
59+ route := filepath.Join(root, "local-mux.sock")
60+ stateDir := filepath.Join(root, "state")
61+ require.NoError(t, os.MkdirAll(stateDir, 0o700))
62+ writeSandboxConfig(t, root, map[string]any{
63+ "marker": brokerconfig.Marker,
64+ "registrations": []any{},
65+ "local": map[string]any{
66+ "identity": brokerLocalTestIdentity,
67+ "displayOrigin": "local",
68+ "route": route,
69+ "policy": map[string]any{
70+ "protocolVersion": policy.ProtocolVersion,
71+ "catalogSchemaVersion": policy.CatalogSchemaVersion,
72+ "environmentPolicy": "client-owned",
73+ "transport": policy.Transport,
74+ "trust": policy.Trust,
75+ "launch": policy.Launch,
76+ "isolation": policy.Isolation,
77+ },
78+ },
79+ })
80+ layout, err := offlineLayout(root)
81+ require.NoError(t, err)
82+ config, err := brokerconfig.Load(layout)
83+ require.NoError(t, err)
84+ binding, ok := config.LocalBinding()
85+ require.True(t, ok)
86+
87+ listener, err := ipc.ListenMux(route)
88+ require.NoError(t, err)
89+ aggregate := daemonmux.NewAggregateListener()
90+ serverBinding := testServerBinding(t, binding)
91+ supervisor, err := daemonmux.NewServerSupervisor(aggregate, serverBinding, daemonmux.DefaultMuxCeilings(), 0)
92+ require.NoError(t, err)
93+
94+ ctx, cancel := context.WithCancel(context.Background())
95+ d := daemon.New(pty.NewFactory(), clock.New(), discardLog(),
96+ daemon.WithShell("/bin/sh", []string{"-c", "trap : TERM; while :; do read line || exit; done"}))
97+ go func() { _ = d.Serve(ctx, aggregate) }()
98+ go func() {
99+ for {
100+ raw, acceptErr := listener.Accept()
101+ if acceptErr != nil {
102+ return
103+ }
104+ go func() { _ = supervisor.Adopt(ctx, raw) }()
105+ }
106+ }()
107+ t.Cleanup(func() {
108+ cancel()
109+ _ = listener.Close()
110+ _ = supervisor.Close()
111+ _ = aggregate.Close()
112+ requireProductionUntouched(t, prodRuntime, prodState)
113+ })
114+
115+ dial := func(ctx context.Context, _ ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
116+ return ipc.DialMuxContext(ctx, route)
117+ }
118+ connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), daemonmux.DefaultMuxCeilings())
119+ require.NoError(t, err)
120+ return &realLocalDaemonFixture{binding: binding, route: route, stateDir: stateDir, connector: connector}
121+}
122+
123+// probe returns one probe over the fixture's real carriage.
124+func (f *realLocalDaemonFixture) probe(t *testing.T) *localRouteProbe {
125+ t.Helper()
126+ dial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
127+ require.Equal(t, f.binding.Route.Address(), target.Address, "the probe dials only its provisioned local address")
128+ return ipc.DialMuxContext(ctx, f.route)
129+ }
130+ probe, err := newLocalRouteProbe(f.binding, brokerLocalTestEpoch, dial, daemonmux.DefaultMuxCeilings())
131+ require.NoError(t, err)
132+ return probe
133+}
134+
135+// openStream opens one logical stream over the fixture's real carriage under
136+// the fixture's authenticated local endpoint.
137+func (f *realLocalDaemonFixture) openStream(t *testing.T, request ports.BrokerOpenStreamRequest) ports.BrokerLogicalConnection {
138+ t.Helper()
139+ ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
140+ t.Cleanup(cancel)
141+ physical, err := f.connector.Connect(ctx, ports.BrokerDialTarget{
142+ Fence: ports.BrokerEndpointFence{Local: true}, Policy: f.binding.Policy, Address: f.binding.Route.Address(),
143+ StartMode: ports.BrokerDaemonExistingOnly,
144+ ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: f.binding.Identity, Bound: true},
145+ })
146+ require.NoError(t, err)
147+ t.Cleanup(func() { _ = physical.Close() })
148+
149+ request.Epoch = brokerLocalTestEpoch
150+ request.Local = true
151+ request.Policy = f.binding.Policy
152+ request.Connection = ports.BrokerConnectionID{4, 4}
153+ if request.Stream == 0 {
154+ f.nextStream++
155+ request.Stream = f.nextStream
156+ }
157+ stream, err := physical.OpenStream(ctx, request)
158+ require.NoError(t, err)
159+ t.Cleanup(func() { _ = stream.Close() })
160+ return stream
161+}
162+
163+// awaitWelcome receives server messages until the typed Welcome arrives.
164+func awaitWelcome(t *testing.T, stream ports.BrokerLogicalConnection) protocol.Welcome {
165+ t.Helper()
166+ type outcome struct {
167+ message protocol.ServerMessage
168+ err error
169+ }
170+ results := make(chan outcome, 1)
171+ go func() {
172+ for {
173+ message, err := stream.ReceiveServer()
174+ if err != nil {
175+ results <- outcome{err: err}
176+ return
177+ }
178+ if welcome, ok := message.(protocol.Welcome); ok {
179+ results <- outcome{message: welcome}
180+ return
181+ }
182+ }
183+ }()
184+ select {
185+ case result := <-results:
186+ require.NoError(t, result.err, "waiting for Welcome")
187+ return result.message.(protocol.Welcome)
188+ case <-time.After(brokerTestWait):
189+ t.Fatal("timed out waiting for Welcome")
190+ return protocol.Welcome{}
191+ }
192+}
193+
194+// TestBrokerLocalProbeRealCatalogueEnablesExactAttach proves the broker-owned
195+// local probe reads a real daemon's catalogue and that the catalogue it
196+// publishes is exactly what makes an exact terminal attach resolvable.
197+func TestBrokerLocalProbeRealCatalogueEnablesExactAttach(t *testing.T) {
198+ fixture := startRealLocalDaemon(t)
199+ probe := fixture.probe(t)
200+
201+ // An observation of a daemon that owns no session reports reachable, known,
202+ // and empty inventory: observing never creates a session.
203+ empty, err := probe.ProbeLocal(context.Background())
204+ require.NoError(t, err)
205+ require.Equal(t, domain.RemoteAvailabilityReachable, empty.Availability)
206+ require.True(t, empty.InventoryKnown)
207+ require.Empty(t, empty.Sessions, "an observation must never create a session")
208+ require.Equal(t, fixture.binding.Identity, empty.Identity)
209+ require.NotZero(t, empty.ProtocolVersion)
210+
211+ // One real session is created through one real attachment stream.
212+ attachment := fixture.openStream(t, ports.BrokerOpenStreamRequest{
213+ Purpose: ports.BrokerStreamAttachment,
214+ Admission: ports.BrokerAdmissionCreateNamed,
215+ Name: "probe-target",
216+ StartMode: ports.BrokerDaemonStartIfNeeded,
217+ })
218+ require.NoError(t, attachment.SendClient(protocol.Hello{
219+ Version: protocol.Version, Intent: protocol.IntentNew, Name: "probe-target",
220+ Size: domain.Size{Cols: 80, Rows: 24}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
221+ }))
222+ welcome := awaitWelcome(t, attachment)
223+ require.NotNil(t, welcome.CommittedIdentity)
224+ target := welcome.CommittedIdentity.Target
225+
226+ // The observation reports the real daemon's catalogue: the exact session
227+ // lifecycle the daemon committed, so the observation is the authority an
228+ // exact attach resolves against.
229+ observed, err := probe.ProbeLocal(context.Background())
230+ require.NoError(t, err)
231+ require.Equal(t, domain.RemoteAvailabilityReachable, observed.Availability)
232+ require.True(t, observed.InventoryKnown)
233+ require.Equal(t, fixture.binding.Identity, observed.Identity)
234+ require.Len(t, observed.Sessions, 1)
235+ require.Equal(t, "probe-target", observed.Sessions[0].Name)
236+ require.Equal(t, target.LifecycleID, observed.Sessions[0].LifecycleID)
237+ require.Equal(t, catalogue.RemoteCatalogSessionUp, observed.Sessions[0].State)
238+
239+ // The published catalogue resolves the exact attach target the client
240+ // composition uses, and that exact target attaches successfully.
241+ resolved, ok := brokerObservationExactTarget(observed, "probe-target")
242+ require.True(t, ok)
243+ require.Equal(t, target, resolved)
244+
245+ exact := fixture.openStream(t, ports.BrokerOpenStreamRequest{
246+ Purpose: ports.BrokerStreamAttachment,
247+ Admission: ports.BrokerAdmissionExact,
248+ Target: resolved,
249+ StartMode: ports.BrokerDaemonStartIfNeeded,
250+ })
251+ exactTarget := resolved
252+ require.NoError(t, exact.SendClient(protocol.Hello{
253+ Version: protocol.Version, Intent: protocol.IntentAttach, Name: resolved.SessionName, ExactTarget: &exactTarget,
254+ Size: domain.Size{Cols: 80, Rows: 24}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
255+ }))
256+ exactWelcome := awaitWelcome(t, exact)
257+ require.NotNil(t, exactWelcome.CommittedIdentity)
258+ require.Equal(t, resolved, exactWelcome.CommittedIdentity.Target)
259+
260+ // Probing creates no durable identity: only a daemon-owned startup path ever
261+ // creates the identity file, and the probe is not one.
262+ _, statErr := os.Lstat(daemonidentity.Path(fixture.stateDir))
263+ require.ErrorIs(t, statErr, os.ErrNotExist, "an observation must never create daemon identity")
264+}
265+
266+// TestBrokerLocalProbeAbsentDaemonStaysUnavailable proves an absence daemon
267+// leaves the local observation unavailable with zero identity and creates
268+// nothing: no session, no process, no durable identity. brokerconfig refuses a
269+// missing or malformed configuration before any probe exists, and an absent
270+// carriage is reported, never started.
271+func TestBrokerLocalProbeAbsentDaemonStaysUnavailable(t *testing.T) {
272+ _, prodRuntime, prodState := isolateSandboxEnv(t)
273+ root := filepath.Join(shortTempDir(t, "vevl"), "sandbox")
274+ require.NoError(t, os.MkdirAll(root, 0o700))
275+ stateDir := filepath.Join(root, "state")
276+ require.NoError(t, os.MkdirAll(stateDir, 0o700))
277+ // A local binding provisioned on a socket nothing listens on: the probe must
278+ // report unavailable rather than start anything.
279+ route := filepath.Join(root, "absent-mux.sock")
280+ writeSandboxConfig(t, root, map[string]any{
281+ "marker": brokerconfig.Marker,
282+ "registrations": []any{},
283+ "local": map[string]any{
284+ "identity": brokerLocalTestIdentity, "displayOrigin": "local", "route": route,
285+ "policy": map[string]any{
286+ "protocolVersion": brokerTestPolicy().ProtocolVersion, "catalogSchemaVersion": brokerTestPolicy().CatalogSchemaVersion,
287+ "environmentPolicy": "client-owned", "transport": brokerTestPolicy().Transport,
288+ "trust": brokerTestPolicy().Trust, "launch": brokerTestPolicy().Launch, "isolation": brokerTestPolicy().Isolation,
289+ },
290+ },
291+ })
292+ layout, err := offlineLayout(root)
293+ require.NoError(t, err)
294+ config, err := brokerconfig.Load(layout)
295+ require.NoError(t, err)
296+ binding, ok := config.LocalBinding()
297+ require.True(t, ok)
298+
299+ var dialed []string
300+ var mu sync.Mutex
301+ probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
302+ mu.Lock()
303+ dialed = append(dialed, target.Address)
304+ mu.Unlock()
305+ return ipc.DialMuxContext(ctx, target.Address)
306+ }, daemonmux.DefaultMuxCeilings())
307+ require.NoError(t, err)
308+
309+ ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
310+ defer cancel()
311+ observation, err := probe.ProbeLocal(ctx)
312+ require.Error(t, err)
313+ require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
314+ require.Zero(t, observation.Identity)
315+ require.False(t, observation.InventoryKnown)
316+ require.Empty(t, observation.Sessions)
317+
318+ mu.Lock()
319+ require.Equal(t, []string{binding.Route.Address()}, dialed, "the probe dials only the address its provisioned local binding published")
320+ mu.Unlock()
321+ // Nothing was created: no socket, no identity, no production path entry.
322+ _, statErr := os.Lstat(route)
323+ require.ErrorIs(t, statErr, os.ErrNotExist, "a probe must never create a carriage")
324+ _, identityErr := os.Lstat(daemonidentity.Path(stateDir))
325+ require.ErrorIs(t, identityErr, os.ErrNotExist, "a probe must never create daemon identity")
326+ requireProductionUntouched(t, prodRuntime, prodState)
327+}
M internal/app/broker_offline_local_test.go +726 -88
@@ -2,12 +2,14 @@ package app
22
33 import (
44 "context"
5+ "encoding/json"
56 "errors"
67 "io"
78 "log/slog"
89 "net"
910 "os"
1011 "path/filepath"
12+ "sync"
1113 "sync/atomic"
1214 "testing"
1315 "time"
@@ -17,14 +19,20 @@ import (
1719 "github.com/bnema/vev/internal/adapters/brokerconfig"
1820 "github.com/bnema/vev/internal/adapters/brokerstore"
1921 "github.com/bnema/vev/internal/adapters/clock"
22+ "github.com/bnema/vev/internal/adapters/daemonidentity"
2023 "github.com/bnema/vev/internal/adapters/daemonmux"
2124 "github.com/bnema/vev/internal/adapters/ipc"
2225 "github.com/bnema/vev/internal/domain"
2326 "github.com/bnema/vev/internal/ports"
27+ "github.com/bnema/vev/internal/protocol"
28+ "github.com/bnema/vev/internal/protocol/catalogue"
2429 "github.com/bnema/vev/internal/usecase/broker"
2530 )
2631
27-const brokerLocalTestIdentity = "offline-local-daemon"
32+const (
33+ brokerLocalTestIdentity = "offline-local-daemon"
34+ brokerLocalTestEpoch = ports.BrokerEpoch(11)
35+)
2836
2937 // testLocalSandboxConfig writes and loads one marker-valid sandbox whose only
3038 // provisioned carriage is the broker-owned local binding.
@@ -51,7 +59,7 @@ func testLocalSandboxConfig(t *testing.T) (*brokerconfig.Config, brokerconfig.Lo
5159 },
5260 },
5361 })
54- layout, err := brokerconfig.ResolveLayout(root, nil)
62+ layout, err := offlineLayout(root)
5563 require.NoError(t, err)
5664 config, err := brokerconfig.Load(layout)
5765 require.NoError(t, err)
@@ -74,81 +82,268 @@ func testServerBinding(t *testing.T, binding brokerconfig.LocalBinding) daemonmu
7482 return serverBinding
7583 }
7684
77-// TestBrokerLocalProbeObservesWithoutAttaching proves the local probe completes
78-// only the daemonmux physical preamble: it reports the daemon's authenticated
79-// identity, incarnation, and negotiated protocol version, and then closes the
80-// carriage without ever opening a logical stream (no attach). Capabilities and
81-// the session catalogue stay unknown because the preamble carries neither.
82-func TestBrokerLocalProbeObservesWithoutAttaching(t *testing.T) {
83- _, binding := testLocalSandboxConfig(t)
84- serverBinding := testServerBinding(t, binding)
85+// testLocalConnector builds the broker-owned endpoint connector a probe
86+// composes, over an explicit dial seam. It is the same connector type the pool
87+// uses, so a test exercises the real composition path.
88+func testLocalConnector(t *testing.T, dial localCarrierDialer) ports.BrokerEndpointConnector {
89+ t.Helper()
90+ connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), daemonmux.DefaultMuxCeilings())
91+ require.NoError(t, err)
92+ return connector
93+}
8594
86- clientConn, serverConn := net.Pipe()
87- t.Cleanup(func() {
88- _ = clientConn.Close()
89- _ = serverConn.Close()
95+// unreachableCarriageConnector builds a connector whose dial always fails, so a
96+// composition test can build a probe without a scripted daemon.
97+func unreachableCarriageConnector(t *testing.T) ports.BrokerEndpointConnector {
98+ t.Helper()
99+ return testLocalConnector(t, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
100+ return nil, errors.New("no carriage")
90101 })
102+}
91103
92- var dialed atomic.Value
93- dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) {
94- dialed.Store(address)
95- return rawFramedTransport(clientConn), nil
96- }
97- probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
104+// testLocalCatalogue builds one exact-schema catalogue carrying the supplied
105+// sessions, marshalled exactly as the daemon's remote-catalog command does.
106+func testLocalCatalogue(t *testing.T, sessions ...catalogue.RemoteCatalogSession) string {
107+ t.Helper()
108+ encoded, err := json.Marshal(catalogue.RemoteCatalog{
109+ ProtocolVersion: protocol.Version,
110+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
111+ Sessions: sessions,
112+ })
98113 require.NoError(t, err)
114+ return string(encoded) + "\n"
115+}
99116
100- // The daemon side completes one preamble and then blocks reading the next
101- // frame. The probe must never send one: the read ends only when the probe
102- // closes the carriage, which is the proof that no attachment stream opened.
103- serverDone := make(chan error, 1)
117+// testLocalUpSession builds one exact, attachable catalogue session with an
118+// ordered tab list.
119+func testLocalUpSession(lifecycle domain.SessionLifecycleID, name string) catalogue.RemoteCatalogSession {
120+ return catalogue.RemoteCatalogSession{
121+ LifecycleID: lifecycle,
122+ Name: name,
123+ State: catalogue.RemoteCatalogSessionUp,
124+ Tabs: []catalogue.RemoteCatalogTab{},
125+ }
126+}
127+
128+// scriptedLocalDaemon is a scripted daemonmux daemon for the broker-owned local
129+// probe: it completes the authenticated physical preamble for the supplied
130+// binding, accepts the logical streams the probe opens, records every typed
131+// client message, and answers from a scripted responder. It never creates a
132+// session or a process and never interprets the probe's traffic beyond the
133+// responder.
134+type scriptedLocalDaemon struct {
135+ supervisor *daemonmux.ServerSupervisor
136+ respond func(protocol.ClientMessage) protocol.ServerMessage
137+
138+ dials atomic.Int32
139+
140+ mu sync.Mutex
141+ streams int
142+ received []protocol.ClientMessage
143+}
144+
145+// newScriptedLocalDaemon serves one scripted daemonmux daemon over in-memory
146+// carriages. The responder answers one typed client message; a nil reply ends
147+// the stream without an answer.
148+func newScriptedLocalDaemon(t *testing.T, binding daemonmux.ServerBinding, respond func(protocol.ClientMessage) protocol.ServerMessage) *scriptedLocalDaemon {
149+ t.Helper()
150+ daemon := &scriptedLocalDaemon{respond: respond}
151+ aggregate := daemonmux.NewAggregateListener()
152+ supervisor, err := daemonmux.NewServerSupervisor(aggregate, binding, daemonmux.DefaultMuxCeilings(), 0)
153+ require.NoError(t, err)
154+ t.Cleanup(func() {
155+ _ = supervisor.Close()
156+ _ = aggregate.Close()
157+ })
104158 go func() {
105- bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn))
159+ for {
160+ connection, acceptErr := aggregate.Accept()
161+ if acceptErr != nil {
162+ return
163+ }
164+ daemon.mu.Lock()
165+ daemon.streams++
166+ daemon.mu.Unlock()
167+ go daemon.serveStream(connection)
168+ }
169+ }()
170+ daemon.supervisor = supervisor
171+ return daemon
172+}
173+
174+// dial returns the probe's carrier dialer: one fresh in-memory carriage whose
175+// daemon end is adopted by the scripted daemon.
176+func (d *scriptedLocalDaemon) dial() localCarrierDialer {
177+ return func(ctx context.Context, _ ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
178+ d.dials.Add(1)
179+ client, server := net.Pipe()
180+ go func() { _ = d.supervisor.Adopt(context.Background(), rawFramedTransport(server)) }()
181+ return rawFramedTransport(client), nil
182+ }
183+}
184+
185+// serveStream records every typed client message of one admitted stream and
186+// answers it from the responder. A responder that returns nil ends the stream
187+// without an answer, so a test can exercise the peer-closed-without-replying
188+// path; a responder that blocks holds the stream open, so a test can exercise
189+// the caller-bounded wait.
190+func (d *scriptedLocalDaemon) serveStream(connection ports.ServerConnection) {
191+ defer func() { _ = connection.Close() }()
192+ for {
193+ message, err := connection.ReceiveClient()
106194 if err != nil {
107- serverDone <- err
108195 return
109196 }
110- if _, err := daemonmux.RunServerHandshake(context.Background(), bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil {
111- serverDone <- err
197+ d.mu.Lock()
198+ d.received = append(d.received, message)
199+ d.mu.Unlock()
200+ if d.respond == nil {
112201 return
113202 }
114- readCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
115- defer cancel()
116- _, recvErr := bridge.Receive(readCtx)
117- serverDone <- recvErr
118- }()
203+ reply := d.respond(message)
204+ if reply == nil {
205+ return
206+ }
207+ if err := connection.SendServer(reply); err != nil {
208+ return
209+ }
210+ }
211+}
212+
213+// messages returns a defensive copy of every recorded client message.
214+func (d *scriptedLocalDaemon) messages() []protocol.ClientMessage {
215+ d.mu.Lock()
216+ defer d.mu.Unlock()
217+ return append([]protocol.ClientMessage(nil), d.received...)
218+}
219+
220+// awaitMessages waits until the daemon recorded at least count messages.
221+func (d *scriptedLocalDaemon) awaitMessages(t *testing.T, count int) []protocol.ClientMessage {
222+ t.Helper()
223+ require.Eventually(t, func() bool {
224+ d.mu.Lock()
225+ defer d.mu.Unlock()
226+ return len(d.received) >= count
227+ }, brokerTestWait, time.Millisecond, "scripted local daemon recorded %d messages", len(d.messages()))
228+ return d.messages()
229+}
230+
231+// awaitStreams waits until the daemon admitted at least count logical streams.
232+func (d *scriptedLocalDaemon) awaitStreams(t *testing.T, count int) {
233+ t.Helper()
234+ require.Eventually(t, func() bool {
235+ d.mu.Lock()
236+ defer d.mu.Unlock()
237+ return d.streams >= count
238+ }, brokerTestWait, time.Millisecond, "scripted local daemon admitted too few streams")
239+}
240+
241+// TestBrokerLocalProbeObservesAuthenticatedCatalogue proves the broker-owned
242+// local probe opens exactly one logical observation stream over the
243+// authenticated daemonmux carriage, asks the daemon for its own catalogue
244+// through the existing typed control protocol, and publishes the observed
245+// identity, incarnation, protocol version, and exact session inventory. It
246+// never sends a Hello, requests no admission, claims no geometry, and creates
247+// nothing.
248+func TestBrokerLocalProbeObservesAuthenticatedCatalogue(t *testing.T) {
249+ _, binding := testLocalSandboxConfig(t)
250+ serverBinding := testServerBinding(t, binding)
251+ lifecycle := domain.SessionLifecycleID{1}
252+ session := testLocalUpSession(lifecycle, "work")
253+
254+ daemon := newScriptedLocalDaemon(t, serverBinding, func(message protocol.ClientMessage) protocol.ServerMessage {
255+ request, ok := message.(protocol.CommandRequest)
256+ if !ok {
257+ return nil
258+ }
259+ return protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandSucceeded, Output: testLocalCatalogue(t, session)}
260+ })
261+
262+ probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, daemon.dial(), daemonmux.DefaultMuxCeilings())
263+ require.NoError(t, err)
119264
120265 observation, err := probe.ProbeLocal(context.Background())
121266 require.NoError(t, err)
122267 require.Equal(t, binding.Identity, observation.Identity)
123- require.Equal(t, ports.BrokerDaemonIncarnation{7, 1}, observation.Incarnation)
124- require.Equal(t, binding.Policy.ProtocolVersion, observation.ProtocolVersion)
268+ require.Equal(t, serverBinding.Incarnation(), observation.Incarnation)
269+ require.Equal(t, protocol.Version, observation.ProtocolVersion)
125270 require.Equal(t, domain.RemoteAvailabilityReachable, observation.Availability)
126- require.Zero(t, observation.Capabilities, "the preamble carries no capabilities; they must stay unknown")
127- require.Empty(t, observation.Sessions)
128- require.False(t, observation.InventoryKnown)
129- require.False(t, observation.Local, "the probe supplies observed state only, never local authority")
271+ require.True(t, observation.InventoryKnown, "an answered catalogue is known inventory")
272+ require.Equal(t, []catalogue.RemoteCatalogSession{session}, observation.Sessions)
273+ require.Zero(t, observation.Capabilities, "the catalogue command carries no capabilities; they must stay unknown")
274+ require.False(t, observation.Local, "the probe supplies observed state only, never configured authority")
130275 require.Empty(t, observation.Endpoint)
131- require.Equal(t, binding.Route.Address(), dialed.Load())
276+ require.Equal(t, domain.RemoteRegistration{}, observation.Registration)
277+
278+ // Exactly one observation stream opened, and its only traffic is one typed,
279+ // correlated remote-catalog request: no Hello means no attachment, no
280+ // session creation, and no geometry claim.
281+ daemon.awaitStreams(t, 1)
282+ messages := daemon.awaitMessages(t, 1)
283+ require.Len(t, messages, 1)
284+ request, ok := messages[0].(protocol.CommandRequest)
285+ require.True(t, ok, "the observation stream must send only a typed command, got %#v", messages[0])
286+ require.Equal(t, "remote-catalog", request.Slug)
287+ require.True(t, request.JSON)
288+ require.False(t, request.Attached, "an observation stream never claims an attachment")
289+ require.NotZero(t, request.RequestID)
290+ require.Equal(t, protocol.Version, request.Version)
291+ for _, message := range messages {
292+ _, hello := message.(protocol.Hello)
293+ require.False(t, hello, "an observation must never send a Hello")
294+ }
295+ require.EqualValues(t, 1, daemon.dials.Load())
296+}
297+
298+// TestBrokerLocalProbeRequestIsObservationOnly proves the one open the local
299+// probe performs is a local observation that may never start the daemon: it
300+// carries no admission, no name, no target, no environment, no endpoint, and
301+// no registration, and it is refused outright when it authorizes a spawn.
302+func TestBrokerLocalProbeRequestIsObservationOnly(t *testing.T) {
303+ _, binding := testLocalSandboxConfig(t)
304+ probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
305+ return nil, nil
306+ }, daemonmux.DefaultMuxCeilings())
307+ require.NoError(t, err)
132308
133- serverErr := <-serverDone
134- require.Error(t, serverErr, "the probe sent no frame after the preamble")
135- require.True(t, errors.Is(serverErr, io.EOF) || errors.Is(serverErr, io.ErrClosedPipe) || errors.Is(serverErr, context.Canceled),
136- "expected a closed carriage, got %v", serverErr)
309+ request, err := probe.request()
310+ require.NoError(t, err)
311+ require.NoError(t, request.Validate())
312+ require.Equal(t, ports.BrokerStreamObservation, request.Purpose)
313+ require.Equal(t, brokerLocalTestEpoch, request.Epoch)
314+ require.True(t, request.Local)
315+ require.Zero(t, request.Admission)
316+ require.Empty(t, request.Name)
317+ require.Empty(t, request.Endpoint)
318+ require.Equal(t, domain.RemoteRegistration{}, request.Registration)
319+ require.Empty(t, request.Env)
320+ require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
321+ require.Equal(t, binding.Policy, request.Policy)
322+ require.NotZero(t, request.Connection)
323+ require.NotZero(t, request.Stream)
324+ // Observation must never start the target: the request carries the
325+ // existing-only authorization, and a spawn-capable one is refused rather
326+ // than silently narrowed.
327+ require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode)
328+ startable := request
329+ startable.StartMode = ports.BrokerDaemonStartIfNeeded
330+ require.Error(t, startable.Validate())
137331 }
138332
139333 // TestBrokerLocalProbeFailsClosed proves an unavailable carriage yields zero
140-// observed identity and an explicit availability, never an invented one, and
141-// that the probe only dials the provisioned local address: it has no spawn path
142-// and never starts a stopped daemon.
334+// observed identity, no inventory, and an explicit availability, never an
335+// invented one, and that the probe only dials the provisioned local address: it
336+// has no spawn path and never starts a stopped daemon.
143337 func TestBrokerLocalProbeFailsClosed(t *testing.T) {
144338 _, binding := testLocalSandboxConfig(t)
145339
146340 var addresses []string
147- dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) {
148- addresses = append(addresses, address)
341+ dial := func(_ context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
342+ require.Equal(t, ports.BrokerDaemonExistingOnly, target.StartMode, "an observation dial never authorizes starting the daemon")
343+ addresses = append(addresses, target.Address)
149344 return nil, errors.New("no carriage")
150345 }
151- probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
346+ probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, dial, daemonmux.DefaultMuxCeilings())
152347 require.NoError(t, err)
153348
154349 observation, err := probe.ProbeLocal(context.Background())
@@ -157,35 +352,149 @@ func TestBrokerLocalProbeFailsClosed(t *testing.T) {
157352 require.Zero(t, observation.Identity)
158353 require.True(t, observation.Incarnation.IsZero())
159354 require.Zero(t, observation.ProtocolVersion)
355+ require.False(t, observation.InventoryKnown)
160356 require.Nil(t, observation.Sessions)
161357 require.Equal(t, []string{binding.Route.Address()}, addresses)
162358
163359 t.Run("a nil carriage is refused", func(t *testing.T) {
164- probe, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) {
360+ probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
165361 return nil, nil
166362 }, daemonmux.DefaultMuxCeilings())
167363 require.NoError(t, err)
168364 observation, err := probe.ProbeLocal(context.Background())
169365 require.Error(t, err)
170366 require.Zero(t, observation.Identity)
367+ require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
171368 })
172369
173370 t.Run("construction refuses a missing dialer", func(t *testing.T) {
174- _, err := newLocalRouteProbe(binding, nil, daemonmux.DefaultMuxCeilings())
371+ _, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, nil, daemonmux.DefaultMuxCeilings())
372+ require.Error(t, err)
373+ })
374+
375+ t.Run("construction refuses a zero epoch", func(t *testing.T) {
376+ _, err := newLocalRouteProbe(binding, 0, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
377+ return nil, nil
378+ }, daemonmux.DefaultMuxCeilings())
175379 require.Error(t, err)
176380 })
177381 }
178382
383+// TestBrokerLocalProbeRejectsInvalidObservation proves every malformed or
384+// mistyped answer fails closed: an uncorrelated result, an explicit failure, a
385+// result that is not a result at all, malformed JSON, and a catalogue outside
386+// the exact current schema all yield an error and an explicit unreachable
387+// observation with zero identity, zero version, and no inventory. A probe never
388+// publishes inventory it could not validate.
389+func TestBrokerLocalProbeRejectsInvalidObservation(t *testing.T) {
390+ _, binding := testLocalSandboxConfig(t)
391+ serverBinding := testServerBinding(t, binding)
392+ session := testLocalUpSession(domain.SessionLifecycleID{1}, "work")
393+
394+ // stalled is released when the subtest ends, so a responder that holds an
395+ // answer open never leaks its goroutine past the test that needed it.
396+ stalled := make(chan struct{})
397+ t.Cleanup(func() { close(stalled) })
398+
399+ valid := testLocalCatalogue(t, session)
400+ badSchema, err := json.Marshal(catalogue.RemoteCatalog{
401+ ProtocolVersion: protocol.Version,
402+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion + 1,
403+ Sessions: []catalogue.RemoteCatalogSession{session},
404+ })
405+ require.NoError(t, err)
406+
407+ tests := []struct {
408+ name string
409+ respond func(protocol.ClientMessage) protocol.ServerMessage
410+ timeout time.Duration
411+ }{
412+ {
413+ name: "uncorrelated result",
414+ respond: func(message protocol.ClientMessage) protocol.ServerMessage {
415+ request, _ := message.(protocol.CommandRequest)
416+ return protocol.CommandResult{RequestID: request.RequestID + 1, Outcome: protocol.CommandSucceeded, Output: valid}
417+ },
418+ },
419+ {
420+ name: "explicit failure",
421+ respond: func(protocol.ClientMessage) protocol.ServerMessage {
422+ return protocol.CommandResult{RequestID: 0, Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: "catalogue unavailable"}
423+ },
424+ },
425+ {
426+ name: "wrong message type",
427+ respond: func(protocol.ClientMessage) protocol.ServerMessage { return protocol.Pong{} },
428+ },
429+ {
430+ name: "malformed json",
431+ respond: func(message protocol.ClientMessage) protocol.ServerMessage {
432+ request, _ := message.(protocol.CommandRequest)
433+ return protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandSucceeded, Output: "{not json"}
434+ },
435+ },
436+ {
437+ name: "schema mismatch",
438+ respond: func(message protocol.ClientMessage) protocol.ServerMessage {
439+ request, _ := message.(protocol.CommandRequest)
440+ return protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandSucceeded, Output: string(badSchema)}
441+ },
442+ },
443+ {
444+ name: "peer closes without answering",
445+ respond: func(protocol.ClientMessage) protocol.ServerMessage { return nil },
446+ },
447+ {
448+ name: "daemon stalls and the attempt deadline expires",
449+ respond: func(protocol.ClientMessage) protocol.ServerMessage {
450+ <-stalled
451+ return nil
452+ },
453+ timeout: 200 * time.Millisecond,
454+ },
455+ }
456+ for _, tt := range tests {
457+ t.Run(tt.name, func(t *testing.T) {
458+ daemon := newScriptedLocalDaemon(t, serverBinding, tt.respond)
459+ probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, daemon.dial(), daemonmux.DefaultMuxCeilings())
460+ require.NoError(t, err)
461+
462+ wait := brokerTestWait
463+ if tt.timeout > 0 {
464+ wait = tt.timeout
465+ }
466+ ctx, cancel := context.WithTimeout(context.Background(), wait)
467+ defer cancel()
468+ observation, err := probe.ProbeLocal(ctx)
469+ require.Error(t, err)
470+ require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
471+ require.Zero(t, observation.Identity)
472+ require.True(t, observation.Incarnation.IsZero())
473+ require.Zero(t, observation.ProtocolVersion)
474+ require.False(t, observation.InventoryKnown)
475+ require.Empty(t, observation.Sessions)
476+ })
477+ }
478+}
479+
179480 // TestBrokerLocalObservationComposition proves the composition derives the
180481 // local producer's authority from the validated configuration and refuses a
181482 // configuration that provisions no local binding.
182483 func TestBrokerLocalObservationComposition(t *testing.T) {
183484 config, binding := testLocalSandboxConfig(t)
184- observation, err := brokerLocalObservation(config, nil)
485+ connector := unreachableCarriageConnector(t)
486+ observation, err := brokerLocalObservation(config, brokerLocalTestEpoch, connector)
185487 require.NoError(t, err)
186488 require.Equal(t, binding.DisplayOrigin, observation.DisplayOrigin)
187489 require.Equal(t, binding.Policy, observation.Policy)
188490 require.NotNil(t, observation.Probe)
491+ // Shared transport: the probe composes the exact connector the pool already
492+ // owns instead of dialing its own, so one broker process holds one transport
493+ // owner per route.
494+ probe, ok := observation.Probe.(*localRouteProbe)
495+ require.True(t, ok)
496+ require.Same(t, connector, probe.connector)
497+ require.Equal(t, brokerLocalTestEpoch, probe.epoch)
189498
190499 t.Run("no local binding provisions no producer", func(t *testing.T) {
191500 root := filepath.Join(shortTempDir(t, "vevl"), "sandbox")
@@ -195,16 +504,24 @@ func TestBrokerLocalObservationComposition(t *testing.T) {
195504 require.NoError(t, err)
196505 config, err := brokerconfig.Load(layout)
197506 require.NoError(t, err)
198- _, err = brokerLocalObservation(config, nil)
507+ _, err = brokerLocalObservation(config, brokerLocalTestEpoch, connector)
199508 require.Error(t, err)
200509 })
201510 }
202511
512+// TestOfflineRegistryConfigSelectsLocalObservationOnlyWhenProvisioned proves the
513+// composition's producer selection is decided by the provisioned configuration
514+// alone, never by the presence of an identity: a provisioned local binding
515+// always observes and runs with mutable membership (so the broker owns host
516+// membership for its whole run, including a zero-host membership), while a
517+// configuration with no local binding is an explicitly configured fixture that
518+// stays observation-disabled.
203519 func TestOfflineRegistryConfigSelectsLocalObservationOnlyWhenProvisioned(t *testing.T) {
204520 localConfig, binding := testLocalSandboxConfig(t)
205- selected, err := offlineRegistryConfig(localConfig, nil)
521+ selected, err := offlineRegistryConfig(localConfig, brokerLocalTestEpoch, unreachableCarriageConnector(t))
206522 require.NoError(t, err)
207523 require.False(t, selected.ObservationDisabled)
524+ require.Equal(t, broker.MembershipMutable, selected.MembershipMode, "a provisioned local binding always owns mutable membership")
208525 require.NotNil(t, selected.Local)
209526 require.Equal(t, binding.DisplayOrigin, selected.Local.DisplayOrigin)
210527 require.Equal(t, binding.Policy, selected.Local.Policy)
@@ -216,41 +533,249 @@ func TestOfflineRegistryConfigSelectsLocalObservationOnlyWhenProvisioned(t *test
216533 require.NoError(t, err)
217534 remoteOnly, err := brokerconfig.Load(layout)
218535 require.NoError(t, err)
219- disabled, err := offlineRegistryConfig(remoteOnly, nil)
536+ disabled, err := offlineRegistryConfig(remoteOnly, brokerLocalTestEpoch, unreachableCarriageConnector(t))
220537 require.NoError(t, err)
221- require.True(t, disabled.ObservationDisabled)
538+ require.True(t, disabled.ObservationDisabled, "a fixture with no local binding stays explicitly disabled")
222539 require.Nil(t, disabled.Local)
223540 }
224541
542+// productionLocalConfig builds one production-shaped broker configuration whose
543+// local binding carries an empty identity, exactly as the broker composes it
544+// before the daemon's first start. The identity is supplied only by the loader.
545+func productionLocalConfig(t *testing.T) (*brokerconfig.Config, brokerconfig.LocalBinding) {
546+ t.Helper()
547+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
548+ t.Setenv("XDG_STATE_HOME", t.TempDir())
549+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
550+ layout := productionBrokerLayout()
551+ path := productionBrokerConfigPath()
552+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
553+ raw, err := json.Marshal(sandboxEmptyDocument("3s"))
554+ require.NoError(t, err)
555+ require.NoError(t, os.WriteFile(path, raw, 0o600))
556+ config, err := brokerconfig.LoadProduction(layout, path, "", localDaemonPolicy(), daemonmux.SocketPath(ipc.SocketDir()))
557+ require.NoError(t, err)
558+ binding, ok := config.LocalBinding()
559+ require.True(t, ok)
560+ require.Empty(t, binding.Identity, "production local authority starts with no identity")
561+ return config, binding
562+}
563+
564+// TestBrokerLocalObservationWithoutIdentityPublishesLocalUnknown proves the
565+// composition observes the local daemon even before an identity exists: the
566+// published snapshot carries exactly one local entry with the explicit unknown
567+// availability and zero observed identity, at a nonzero epoch and revision. A
568+// broker may therefore precede its daemon and still publish a valid catalogue.
569+func TestBrokerLocalObservationWithoutIdentityPublishesLocalUnknown(t *testing.T) {
570+ config, _ := productionLocalConfig(t)
571+ // The loader re-reads an absent identity on every attempt, so the probe is
572+ // dynamic: it never dials and never spawns while no identity exists.
573+ loader := localIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return "", nil })
574+ var dials atomic.Int32
575+ connector := testLocalConnector(t, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
576+ dials.Add(1)
577+ return nil, errors.New("an absent identity must never dial")
578+ })
579+ local, err := brokerLocalObservation(config, brokerLocalTestEpoch, connector, loader)
580+ require.NoError(t, err)
581+
582+ stateDir := filepath.Join(shortTempDir(t, "vevl"), "state")
583+ store, err := brokerstore.Open(brokerstore.Options{Dir: stateDir})
584+ require.NoError(t, err)
585+ t.Cleanup(func() { require.NoError(t, store.Close()) })
586+ registry, err := broker.NewRegistryWithConfig(brokerLocalTestEpoch, store, nil, clock.New(), discardLog(), broker.RegistryConfig{Local: local, MembershipMode: broker.MembershipMutable})
587+ require.NoError(t, err)
588+ startLocalRegistry(t, registry)
589+
590+ observation := waitLocalEntry(t, registry, func(entry ports.BrokerDaemonObservation) bool { return !entry.Checking })
591+ require.True(t, observation.Local)
592+ require.Equal(t, domain.RemoteAvailabilityUnknown, observation.Availability, "an unobserved local daemon is explicitly unknown, never invented")
593+ require.Empty(t, observation.Identity)
594+ require.True(t, observation.Incarnation.IsZero())
595+ require.Zero(t, observation.ProtocolVersion)
596+ require.False(t, observation.InventoryKnown)
597+ require.Empty(t, observation.Sessions)
598+
599+ snapshot := registry.Snapshot()
600+ require.NotZero(t, snapshot.Epoch, "a published snapshot always carries an epoch")
601+ require.NotZero(t, snapshot.Revision, "a published snapshot always carries a nonzero revision")
602+ require.Len(t, snapshot.Daemons, 1, "exactly one local entry is published")
603+ require.True(t, snapshot.Daemons[0].Local)
604+ require.Equal(t, int32(0), dials.Load(), "an absent identity never dials and never spawns")
605+
606+ // Never durable: the store is asked for its durable snapshot and the local
607+ // entry is absent from it.
608+ durable, err := store.Load()
609+ require.NoError(t, err)
610+ require.Empty(t, durable.Daemons, "a local observation is never durable")
611+}
612+
613+// TestBrokerLocalObservationPublishesReachableCatalogueWhenIdentityAppears
614+// proves the same broker starts observing without a restart once the daemon
615+// publishes its identity: the loader is re-read on every attempt, so the next
616+// observation authenticates the local carriage and publishes the reachable
617+// catalogue.
618+func TestBrokerLocalObservationPublishesReachableCatalogueWhenIdentityAppears(t *testing.T) {
619+ config, binding := productionLocalConfig(t)
620+ // The daemon publishes its own identity; the composition's binding starts
621+ // empty and is filled by the loader the moment the daemon first starts.
622+ daemonIdentity := ports.BrokerDaemonIdentity(brokerLocalTestIdentity)
623+ serverBinding, err := daemonmux.NewServerBinding(daemonIdentity, ports.BrokerDaemonIncarnation{7, 1}, binding.Policy)
624+ require.NoError(t, err)
625+ session := testLocalUpSession(domain.SessionLifecycleID{5}, "appeared")
626+ daemon := newScriptedLocalDaemon(t, serverBinding, func(message protocol.ClientMessage) protocol.ServerMessage {
627+ request, ok := message.(protocol.CommandRequest)
628+ if !ok {
629+ return nil
630+ }
631+ return protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandSucceeded, Output: testLocalCatalogue(t, session)}
632+ })
633+
634+ // The identity is absent at first and appears before the next attempt; the
635+ // scripted daemon answers under that identity.
636+ var identity atomic.Value
637+ identity.Store(ports.BrokerDaemonIdentity(""))
638+ connector := testLocalConnector(t, daemon.dial())
639+ local, err := brokerLocalObservation(config, brokerLocalTestEpoch, connector, func() (ports.BrokerDaemonIdentity, error) {
640+ return identity.Load().(ports.BrokerDaemonIdentity), nil
641+ })
642+ require.NoError(t, err)
643+
644+ stateDir := filepath.Join(shortTempDir(t, "vevl"), "state")
645+ store, err := brokerstore.Open(brokerstore.Options{Dir: stateDir})
646+ require.NoError(t, err)
647+ t.Cleanup(func() { require.NoError(t, store.Close()) })
648+ registry, err := broker.NewRegistryWithConfig(brokerLocalTestEpoch, store, nil, clock.New(), discardLog(), broker.RegistryConfig{Local: local, MembershipMode: broker.MembershipMutable})
649+ require.NoError(t, err)
650+ startLocalRegistry(t, registry)
651+
652+ waitLocalEntry(t, registry, func(entry ports.BrokerDaemonObservation) bool { return !entry.Checking })
653+ require.Equal(t, int32(0), daemon.dials.Load(), "no dial happens while no identity exists")
654+
655+ identity.Store(daemonIdentity)
656+ observed := waitLocalEntry(t, registry, func(entry ports.BrokerDaemonObservation) bool {
657+ return entry.Availability == domain.RemoteAvailabilityReachable
658+ })
659+ require.Equal(t, daemonIdentity, observed.Identity)
660+ require.Equal(t, serverBinding.Incarnation(), observed.Incarnation)
661+ require.Equal(t, protocol.Version, observed.ProtocolVersion)
662+ require.True(t, observed.InventoryKnown)
663+ require.Equal(t, []catalogue.RemoteCatalogSession{session}, observed.Sessions)
664+ require.Positive(t, daemon.dials.Load())
665+
666+ // The published catalogue resolves the exact attach target the client
667+ // composition uses, so the appearing daemon is immediately usable.
668+ _, ok := brokerObservationExactTarget(observed, "appeared")
669+ require.True(t, ok)
670+}
671+
672+// TestBrokerLocalObservationNeverSpawnsDaemon proves the local observation is
673+// spawn-free: every dial carries the existing-only authorization, so observing
674+// a stopped daemon can never start one, and no dial happens without an identity.
675+func TestBrokerLocalObservationNeverSpawnsDaemon(t *testing.T) {
676+ _, binding := testLocalSandboxConfig(t)
677+ var modes []ports.BrokerDaemonStartMode
678+ var mu sync.Mutex
679+ probe, err := newDynamicLocalRouteProbe(binding.Route, binding.Policy, brokerLocalTestEpoch, testEndpointConnectorFunc(func(_ context.Context, target ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) {
680+ mu.Lock()
681+ modes = append(modes, target.StartMode)
682+ mu.Unlock()
683+ return nil, errors.New("socket absent")
684+ }), func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil })
685+ require.NoError(t, err)
686+
687+ _, err = probe.ProbeLocal(context.Background())
688+ require.Error(t, err)
689+ mu.Lock()
690+ require.Equal(t, []ports.BrokerDaemonStartMode{ports.BrokerDaemonExistingOnly}, modes, "an observation never authorizes starting the daemon")
691+ mu.Unlock()
692+}
693+
694+// TestOfflineRegistryConfigExplicitDisabledFixtureStaysDisabled proves an
695+// explicitly configured fixture that provisions no local binding remains a
696+// read-only snapshot owner: it issues no probes, arms no timers, and never
697+// reconciles, so its disabled mode is never inferred from an identity.
698+func TestOfflineRegistryConfigExplicitDisabledFixtureStaysDisabled(t *testing.T) {
699+ root := filepath.Join(shortTempDir(t, "vevl"), "sandbox")
700+ require.NoError(t, os.MkdirAll(root, 0o700))
701+ writeSandboxConfig(t, root, sandboxEmptyDocument("3s"))
702+ layout, err := brokerconfig.ResolveLayout(root, nil)
703+ require.NoError(t, err)
704+ config, err := brokerconfig.Load(layout)
705+ require.NoError(t, err)
706+
707+ selected, err := offlineRegistryConfig(config, brokerLocalTestEpoch, unreachableCarriageConnector(t))
708+ require.NoError(t, err)
709+ require.True(t, selected.ObservationDisabled)
710+ require.Nil(t, selected.Local)
711+
712+ stateDir := filepath.Join(shortTempDir(t, "vevl"), "state")
713+ store, err := brokerstore.Open(brokerstore.Options{Dir: stateDir})
714+ require.NoError(t, err)
715+ t.Cleanup(func() { require.NoError(t, store.Close()) })
716+ registry, err := broker.NewRegistryWithConfig(brokerLocalTestEpoch, store, nil, clock.New(), discardLog(), selected)
717+ require.NoError(t, err)
718+ startLocalRegistry(t, registry)
719+
720+ // A disabled registry publishes its restored durable state and then stays
721+ // read-only: no probe, no reconcile, and a zero-host mutation is refused.
722+ require.Eventually(t, func() bool { return registry.Snapshot().Epoch != 0 }, brokerTestWait, time.Millisecond)
723+ _, err = registry.AddHost(context.Background(), "user@example.com", localDaemonPolicy())
724+ require.ErrorIs(t, err, ports.ErrBrokerMembershipImmutable, "an explicitly disabled fixture owns no mutable membership")
725+}
726+
727+// TestProductionRegistryOwnsMutableMembershipWithZeroHosts proves the production
728+// composition owns host membership for its whole run even when it starts with
729+// no configured host at all: the broker may precede every registration, so a
730+// first host can be added and removed through the same authority the zero-host
731+// run already published.
732+func TestProductionRegistryOwnsMutableMembershipWithZeroHosts(t *testing.T) {
733+ config, _ := productionLocalConfig(t)
734+ local, err := brokerLocalObservation(config, brokerLocalTestEpoch, unreachableCarriageConnector(t), func() (ports.BrokerDaemonIdentity, error) { return "", nil })
735+ require.NoError(t, err)
736+ selected, err := offlineRegistryConfig(config, brokerLocalTestEpoch, unreachableCarriageConnector(t))
737+ require.NoError(t, err)
738+ require.Equal(t, broker.MembershipMutable, selected.MembershipMode)
739+
740+ stateDir := filepath.Join(shortTempDir(t, "vevl"), "state")
741+ store, err := brokerstore.Open(brokerstore.Options{Dir: stateDir})
742+ require.NoError(t, err)
743+ t.Cleanup(func() { require.NoError(t, store.Close()) })
744+ // The production remote probe is required exactly when membership is added,
745+ // so this fixture supplies one that never dials: the test asserts membership
746+ // authority, not observation.
747+ registry, err := broker.NewRegistryWithConfig(brokerLocalTestEpoch, store, unreachableHostProbe(t), clock.New(), discardLog(), broker.RegistryConfig{Local: local, MembershipMode: broker.MembershipMutable})
748+ require.NoError(t, err)
749+ startLocalRegistry(t, registry)
750+
751+ require.Empty(t, registry.Snapshot().Daemons[0].Registration, "the local entry carries no configured registration")
752+ registration, err := registry.AddHost(context.Background(), "user@example.com", remoteBrokerPolicy("quic"))
753+ require.NoError(t, err, "a zero-host production run must admit a first host")
754+ require.Equal(t, "user@example.com", registration.Endpoint)
755+
756+ removed, err := registry.RemoveHost(context.Background(), registration)
757+ require.NoError(t, err)
758+ require.True(t, removed)
759+}
760+
225761 // TestBrokerLocalObservationRegistryPublishesLocalFirst proves the composition
226762 // end to end: the registry observes the local daemon through the broker-owned
227-// probe and publishes it at index zero, and the durable store persists no local
228-// entry (the local observation is never durable).
763+// probe and publishes it at index zero with the observed inventory, and the
764+// durable store persists no local entry (the local observation is never
765+// durable).
229766 func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) {
230- _, binding := testLocalSandboxConfig(t)
767+ config, binding := testLocalSandboxConfig(t)
231768 serverBinding := testServerBinding(t, binding)
769+ session := testLocalUpSession(domain.SessionLifecycleID{9}, "registry")
232770
233- var dials atomic.Int32
234- dial := func(ctx context.Context, _ string) (daemonmux.RawFramedTransport, error) {
235- dials.Add(1)
236- clientConn, serverConn := net.Pipe()
237- go func() {
238- defer serverConn.Close()
239- bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn))
240- if err != nil {
241- return
242- }
243- if _, err := daemonmux.RunServerHandshake(ctx, bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil {
244- return
245- }
246- // Hold the carriage until the probe closes it or the attempt is
247- // retired, so the daemon side owns no logical stream either.
248- <-ctx.Done()
249- _ = bridge.Close()
250- }()
251- return rawFramedTransport(clientConn), nil
252- }
253- probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
771+ daemon := newScriptedLocalDaemon(t, serverBinding, func(message protocol.ClientMessage) protocol.ServerMessage {
772+ request, ok := message.(protocol.CommandRequest)
773+ if !ok {
774+ return nil
775+ }
776+ return protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandSucceeded, Output: testLocalCatalogue(t, session)}
777+ })
778+ local, err := brokerLocalObservation(config, brokerLocalTestEpoch, testLocalConnector(t, daemon.dial()))
254779 require.NoError(t, err)
255780
256781 stateDir := filepath.Join(shortTempDir(t, "vevs"), "state")
@@ -259,9 +784,7 @@ func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) {
259784 require.NoError(t, err)
260785 t.Cleanup(func() { require.NoError(t, store.Close()) })
261786
262- registry, err := broker.NewRegistryWithConfig(7, store, nil, clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)), broker.RegistryConfig{
263- Local: &broker.LocalObservation{DisplayOrigin: binding.DisplayOrigin, Policy: binding.Policy, Probe: probe},
264- })
787+ registry, err := broker.NewRegistryWithConfig(brokerLocalTestEpoch, store, nil, clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)), broker.RegistryConfig{Local: local})
265788 require.NoError(t, err)
266789
267790 ctx, cancel := context.WithCancel(context.Background())
@@ -279,7 +802,10 @@ func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) {
279802 require.Len(t, snapshot.Daemons, 1)
280803 require.True(t, snapshot.Daemons[0].Local)
281804 require.Equal(t, binding.Identity, snapshot.Daemons[0].Identity)
282- require.Positive(t, dials.Load())
805+ require.Equal(t, serverBinding.Incarnation(), snapshot.Daemons[0].Incarnation)
806+ require.True(t, snapshot.Daemons[0].InventoryKnown)
807+ require.Equal(t, []catalogue.RemoteCatalogSession{session}, snapshot.Daemons[0].Sessions)
808+ require.Positive(t, daemon.dials.Load())
283809
284810 // The durable store persists remote observations only: the local entry never
285811 // reaches disk.
@@ -289,11 +815,79 @@ func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) {
289815 }, 3*time.Second, 2*time.Millisecond)
290816 stored, err := store.Load()
291817 require.NoError(t, err)
292- for _, daemon := range stored.Daemons {
293- require.False(t, daemon.Local, "a local observation must never be durable")
818+ for _, entry := range stored.Daemons {
819+ require.False(t, entry.Local, "a local observation must never be durable")
294820 }
295821 }
296822
823+// TestBrokerLocalProbeDialTargetIsExistingOnly proves the broker-owned local
824+// observation dial target carries the existing-only authorization: an
825+// observation must never start a stopped daemon, and the resolved target is
826+// where that decision is carried to the transport.
827+type testEndpointConnectorFunc func(context.Context, ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error)
828+
829+func (f testEndpointConnectorFunc) Connect(ctx context.Context, target ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) {
830+ return f(ctx, target)
831+}
832+
833+func TestBrokerLocalProbeLoadsIdentityOnEveryAttempt(t *testing.T) {
834+ _, binding := testLocalSandboxConfig(t)
835+ var identity ports.BrokerDaemonIdentity
836+ var dials []ports.BrokerDialTarget
837+ probe, err := newDynamicLocalRouteProbe(binding.Route, binding.Policy, brokerLocalTestEpoch, testEndpointConnectorFunc(func(_ context.Context, target ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) {
838+ dials = append(dials, target)
839+ return nil, errors.New("socket absent")
840+ }), func() (ports.BrokerDaemonIdentity, error) {
841+ return identity, nil
842+ })
843+ require.NoError(t, err)
844+
845+ observation, err := probe.ProbeLocal(context.Background())
846+ require.NoError(t, err)
847+ require.Equal(t, domain.RemoteAvailabilityUnknown, observation.Availability)
848+ require.Empty(t, dials, "an absent identity cannot dial or spawn")
849+
850+ identity = binding.Identity
851+ observation, err = probe.ProbeLocal(context.Background())
852+ require.Error(t, err)
853+ require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
854+ require.Len(t, dials, 1)
855+ require.Equal(t, ports.BrokerExpectedIdentity{Identity: binding.Identity, Bound: true}, dials[0].ExpectedIdentity)
856+ require.Equal(t, ports.BrokerDaemonExistingOnly, dials[0].StartMode)
857+}
858+
859+func TestBrokerLocalProbeIdentityCorruptionIsDiagnosticWithoutDial(t *testing.T) {
860+ _, binding := testLocalSandboxConfig(t)
861+ dialed := false
862+ probe, err := newDynamicLocalRouteProbe(binding.Route, binding.Policy, brokerLocalTestEpoch, testEndpointConnectorFunc(func(context.Context, ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) {
863+ dialed = true
864+ return nil, nil
865+ }), func() (ports.BrokerDaemonIdentity, error) {
866+ return "", daemonidentity.ErrInvalid
867+ })
868+ require.NoError(t, err)
869+ observation, err := probe.ProbeLocal(context.Background())
870+ require.ErrorIs(t, err, daemonidentity.ErrInvalid)
871+ require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
872+ require.False(t, dialed)
873+}
874+
875+func TestBrokerLocalProbeDialTargetIsExistingOnly(t *testing.T) {
876+ _, binding := testLocalSandboxConfig(t)
877+ var target ports.BrokerDialTarget
878+ probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(_ context.Context, observed ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
879+ target = observed
880+ return nil, errors.New("unreachable")
881+ }, daemonmux.DefaultMuxCeilings())
882+ require.NoError(t, err)
883+ _, err = probe.ProbeLocal(context.Background())
884+ require.Error(t, err)
885+ require.Equal(t, ports.BrokerDaemonExistingOnly, target.StartMode,
886+ "an observation dial target never authorizes starting the daemon")
887+ require.True(t, target.Fence.Local)
888+ require.Equal(t, binding.Identity, target.ExpectedIdentity.Identity)
889+}
890+
297891 // TestBrokerLocalProbeRefusesNonLocalRoute proves the probe refuses a
298892 // hand-built binding whose route is an ssh helper route: probing must never
299893 // start a child process, so only a Unix route this process dials directly is
@@ -317,7 +911,7 @@ func TestBrokerLocalProbeRefusesNonLocalRoute(t *testing.T) {
317911 Policy: brokerTestPolicy(),
318912 Route: sshRoute,
319913 }
320- _, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) {
914+ _, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
321915 t.Fatal("an ssh route must never be dialed by the local probe")
322916 return nil, nil
323917 }, daemonmux.DefaultMuxCeilings())
@@ -334,3 +928,47 @@ func TestBrokerLocalProbeNilReceiverFailsClosed(t *testing.T) {
334928 require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
335929 require.NotZero(t, observation.Availability, "availability is never zero")
336930 }
931+
932+// startLocalRegistry runs one registry for the duration of a test.
933+func startLocalRegistry(t *testing.T, registry *broker.Registry) {
934+ t.Helper()
935+ ctx, cancel := context.WithCancel(context.Background())
936+ done := make(chan struct{})
937+ go func() { defer close(done); registry.Run(ctx) }()
938+ t.Cleanup(func() { cancel(); <-done })
939+}
940+
941+// waitLocalEntry waits for the published local entry (always index zero) to
942+// satisfy predicate and returns it.
943+func waitLocalEntry(t *testing.T, registry *broker.Registry, predicate func(ports.BrokerDaemonObservation) bool) ports.BrokerDaemonObservation {
944+ t.Helper()
945+ sub := registry.Subscribe()
946+ defer sub.Close()
947+ deadline := time.After(brokerTestWait)
948+ for {
949+ snapshot := registry.Snapshot()
950+ if len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Local && predicate(snapshot.Daemons[0]) {
951+ return snapshot.Daemons[0]
952+ }
953+ select {
954+ case <-sub.Changed():
955+ case <-deadline:
956+ t.Fatal("timed out waiting for the published local entry")
957+ return ports.BrokerDaemonObservation{}
958+ }
959+ }
960+}
961+
962+// noDialHostProbe is a ports.BrokerHostProbe that never dials: a membership test
963+// supplies it only because the registry requires a remote probe to observe
964+// hosts, never to own their authority.
965+type noDialHostProbe struct{}
966+
967+func (noDialHostProbe) Probe(context.Context, domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) {
968+ return ports.BrokerDaemonObservation{}, errors.New("membership authority must not dial")
969+}
970+
971+func unreachableHostProbe(t *testing.T) ports.BrokerHostProbe {
972+ t.Helper()
973+ return noDialHostProbe{}
974+}
M internal/app/broker_offline_mux.go +218 -63
@@ -19,6 +19,7 @@ import (
1919 "github.com/bnema/vev/internal/adapters/ipc"
2020 "github.com/bnema/vev/internal/adapters/quic"
2121 "github.com/bnema/vev/internal/adapters/sshstdio"
22+ "github.com/bnema/vev/internal/ports"
2223 "github.com/bnema/vev/internal/protocol/wire"
2324 )
2425
@@ -30,9 +31,9 @@ import (
3031 // QUIC bootstrap that pins a freshly minted ephemeral certificate. This file
3132 // owns both halves.
3233 //
33-// The broker side is a transport-selecting daemonmux.EndpointConnector. It
34-// receives only the opaque address the immutable resolver published, looks the
35-// route up in the configuration it was composed with, and dials that route. It
34+// The broker side is a transport-selecting daemonmux.EndpointConnector. In
35+// production it rechecks remote targets against committed registry records;
36+// sandbox fixtures retain their immutable configured routes and trust inputs. It
3637 // never authenticates the carriage itself: the daemonmux physical preamble
3738 // verifies the daemon identity, incarnation, and policy the resolver already
3839 // pinned. One caller-supplied setup context bounds bootstrap, pinned dial, and
@@ -49,10 +50,11 @@ import (
4950 // server, admits exactly one authenticated carriage, and bridges it to the
5051 // same private Unix daemonmux carriage.
5152 //
52-// A helper only ever bridges to the configured private Unix daemonmux
53-// endpoint. It never starts a broker, an observer, or an ordinary daemon, never
54-// dials the production daemon socket, and never fabricates a daemon
55-// incarnation: the daemon on the other side of the carriage owns that value.
53+// Each helper also accepts --production instead of --offline-root. That scope
54+// derives the private carriage from this user's production runtime. Helpers
55+// never start a broker or observer. Only an explicit if-needed authorization
56+// permits the shared daemon starter; existing-only observation never spawns.
57+// The daemon on the other side owns its identity and incarnation.
5658 // The one-time QUIC token and nonce are minted per physical connection, travel
5759 // only inside the SSH channel and the encrypted stream, and never reach an
5860 // address, a log, or an error. SSH host-key verification, noninteractive
@@ -93,20 +95,43 @@ const brokerMuxQUICDialTimeout = 15 * time.Second
9395 // setup shutdown past this delay; the direct child is still killed and reaped.
9496 const brokerMuxWaitDelay = 2 * time.Second
9597
98+// brokerDaemonStartArg is the closed helper flag that carries one daemon-start
99+// authorization across the SSH process boundary. It is added by the broker to
100+// the provisioned remote argv and parsed strictly by the helper; the value is
101+// brokerDaemonStartArgvFlag's exact spelling, so the two sides can never drift.
102+const brokerDaemonStartArg = "--daemon-start"
103+
96104 // brokerMuxOptions is the parsed hidden mux helper invocation.
97105 type brokerMuxOptions struct {
106+ production bool
98107 offlineRoot string
108+ // startMode is the daemon-start authorization the broker propagated. An
109+ // absent flag is the safe default: the helper observes only what is already
110+ // running and never starts a daemon, a broker, or a recursive helper.
111+ startMode ports.BrokerDaemonStartMode
99112 }
100113
101-// parseBrokerMuxArgs strictly parses `<hidden> --offline-root ABS`. Unknown or
102-// duplicate flags, a missing value, and positionals are refused, exactly like
103-// the foreground sandbox entry point.
114+// parseBrokerMuxArgs strictly parses `<hidden> (--offline-root ABS | --production)
115+// [--daemon-start existing-only|if-needed]`. Unknown or duplicate flags, a
116+// missing or unknown value, and positionals are refused, exactly like the
117+// foreground sandbox entry point. An absent --daemon-start defaults to
118+// existing-only, and an unrecognized value is never mapped onto a permissive
119+// authorization.
104120 func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, error) {
105- options := brokerMuxOptions{}
106- rootSet := false
121+ options := brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly}
122+ rootSet, startSet := false, false
107123 for index := 0; index < len(args); {
108124 switch args[index] {
125+ case "--production":
126+ if options.production || rootSet {
127+ return command{}, usagef("`%s` received duplicate or conflicting scope", name)
128+ }
129+ options.production = true
130+ index++
109131 case "--offline-root":
132+ if options.production {
133+ return command{}, usagef("`%s` received conflicting scope", name)
134+ }
110135 if rootSet {
111136 return command{}, usagef("`%s` received duplicate --offline-root", name)
112137 }
@@ -116,6 +141,20 @@ func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, erro
116141 options.offlineRoot = args[index+1]
117142 rootSet = true
118143 index += 2
144+ case brokerDaemonStartArg:
145+ if startSet {
146+ return command{}, usagef("`%s` received duplicate %s", name, brokerDaemonStartArg)
147+ }
148+ if index+1 >= len(args) {
149+ return command{}, usagef("`%s` requires a value", brokerDaemonStartArg)
150+ }
151+ mode, err := parseBrokerDaemonStartArgvFlag(args[index+1])
152+ if err != nil {
153+ return command{}, usagef("`%s`: %s", name, err)
154+ }
155+ options.startMode = mode
156+ startSet = true
157+ index += 2
119158 default:
120159 if strings.HasPrefix(args[index], "-") {
121160 return command{}, usagef("unknown flag %q for `%s`", args[index], name)
@@ -123,8 +162,8 @@ func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, erro
123162 return command{}, usagef("`%s` does not accept positional arguments", name)
124163 }
125164 }
126- if !rootSet {
127- return command{}, usagef("`%s` requires --offline-root", name)
165+ if !rootSet && !options.production {
166+ return command{}, usagef("`%s` requires --offline-root or --production", name)
128167 }
129168 return command{kind: kind, brokerMux: options}, nil
130169 }
@@ -141,51 +180,134 @@ func loadMuxHelperConfig(offlineRoot string) (*brokerconfig.Config, error) {
141180 }
142181
143182 // brokerMuxConnector builds the transport-selecting daemonmux endpoint
144-// connector over one immutable configuration. The dial function receives only
145-// the opaque address the resolver published and refuses any address the
146-// configuration did not produce, so a request can never select a transport or
147-// an address of its own.
183+// connector over one immutable configuration. The dial function receives the
184+// whole resolved target the pool will authenticate against and refuses any
185+// address the configuration did not produce, so a request can never select a
186+// transport, an address, or a start authorization of its own. It re-checks that
187+// the route's provisioned authority still matches the resolved policy and fence
188+// before dialing, so the transport is only ever reached under the authority the
189+// resolver published.
148190 func brokerMuxConnector(config *brokerconfig.Config, log *slog.Logger) (*daemonmux.EndpointConnector, error) {
149191 if config == nil {
150192 return nil, errors.New("vev: broker mux connector requires a configuration")
151193 }
152- dial := func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) {
153- route, ok := config.RouteByAddress(address)
194+ dial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
195+ route, ok := config.RouteByAddress(target.Address)
154196 if !ok {
155197 return nil, fmt.Errorf("vev: broker mux: unknown route address")
156198 }
157- return dialBrokerRoute(ctx, route, log)
199+ if err := brokerRouteAuthorityMatches(config, target); err != nil {
200+ return nil, err
201+ }
202+ return dialBrokerRoute(ctx, route, target, log)
158203 }
159204 return daemonmux.NewEndpointConnector(dial, daemonmux.DefaultMuxCeilings())
160205 }
161206
162-// dialBrokerRoute dials one provisioned route as an already-authenticated raw
163-// framed carriage. Authentication, identity, and policy remain the daemonmux
164-// connector's concern; this function only selects the transport.
165-func dialBrokerRoute(ctx context.Context, route brokerconfig.Route, log *slog.Logger) (daemonmux.RawFramedTransport, error) {
207+// brokerDynamicMuxConnector keeps the configured local carriage, but remote
208+// targets must still match committed registry authority at dial time.
209+func brokerDynamicMuxConnector(config *brokerconfig.Config, routes *brokerRoutes, log *slog.Logger) (*daemonmux.EndpointConnector, error) {
210+ return daemonmux.NewEndpointConnector(func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
211+ if !target.Fence.Local {
212+ route, err := routes.remoteRoute(ctx, target)
213+ if err != nil {
214+ return nil, err
215+ }
216+ return dialBrokerRoute(ctx, route, target, log)
217+ }
218+ if err := brokerRouteAuthorityMatches(config, target); err != nil {
219+ return nil, err
220+ }
221+ route, ok := config.RouteByAddress(target.Address)
222+ if !ok {
223+ return nil, errors.New("vev: unknown local route")
224+ }
225+ return dialBrokerRoute(ctx, route, target, log)
226+ }, daemonmux.DefaultMuxCeilings())
227+}
228+
229+// brokerRouteAuthorityMatches re-verifies that the route the opaque address
230+// selected is still the one the resolved target's authority names: the same
231+// fence, and a policy the provisioned entry accepts. The pool and the daemonmux
232+// preamble already check this; re-checking here keeps the dial decision and the
233+// authentication decision on one authority instead of two that could drift.
234+func brokerRouteAuthorityMatches(config *brokerconfig.Config, target ports.BrokerDialTarget) error {
235+ if target.Fence.Local {
236+ binding, ok := config.LocalBinding()
237+ if !ok || binding.Route.Address() != target.Address || !binding.Policy.Compatible(target.Policy) {
238+ return ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("vev: broker mux: local route authority does not match the resolved target")}
239+ }
240+ return nil
241+ }
242+ for _, endpoint := range config.Endpoints() {
243+ registration, ok := config.Registration(endpoint)
244+ if !ok || registration.Route.Address() != target.Address {
245+ continue
246+ }
247+ if registration.Registration.Equal(target.Fence.Registration) && registration.Policy.Compatible(target.Policy) {
248+ return nil
249+ }
250+ }
251+ return ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("vev: broker mux: route authority does not match the resolved target")}
252+}
253+
254+// dialBrokerRoute dials one provisioned route under one resolved target as an
255+// already-authenticated raw framed carriage. Authentication, identity, and
256+// policy remain the daemonmux connector's concern; this function selects the
257+// transport and forwards the whole target, so a mux stdio/QUIC helper receives
258+// the start authorization before it decides anything about the remote daemon.
259+//
260+// A Unix route is dialed directly by this process. The configuration's own
261+// local binding route is the machine daemon the broker may start, so it goes
262+// through the broker-owned local daemon start; every other Unix route is a
263+// provisioned endpoint carriage that the broker only ever dials.
264+func dialBrokerRoute(ctx context.Context, route brokerconfig.Route, target ports.BrokerDialTarget, log *slog.Logger) (daemonmux.RawFramedTransport, error) {
166265 switch route.Kind() {
167266 case brokerconfig.RouteUnix:
267+ if target.Fence.Local {
268+ // The broker-owned local fence is the machine daemon this process may
269+ // start; it goes through the shared daemon start so the authorization
270+ // reaches the election. Every other Unix carriage is only dialed.
271+ return dialBrokerLocalDaemon(ctx, route.Path(), target)
272+ }
168273 return ipc.DialMuxContext(ctx, route.Path())
169274 case brokerconfig.RouteSSHStdio:
170- return sshstdio.DialMuxContext(ctx, sshMuxCommandSpec(route), log)
275+ spec, err := sshMuxCommandSpec(route, target.StartMode)
276+ if err != nil {
277+ return nil, err
278+ }
279+ return sshstdio.DialMuxContext(ctx, spec, log)
171280 case brokerconfig.RouteSSHQUIC:
172- return dialBrokerQUICRoute(ctx, route, log)
281+ return dialBrokerQUICRoute(ctx, route, target.StartMode, log)
173282 default:
174283 return nil, fmt.Errorf("vev: broker mux: unsupported route kind %q", route.Kind())
175284 }
176285 }
177286
178-// sshMuxCommandSpec builds the local ssh argv for one provisioned route. It
179-// reuses the mux carriage's own command construction (the option terminator,
180-// the POSIX-quoted remote argv, and `-T` for no remote PTY) and splices in only
181-// the explicit trust inputs the operator provisioned. It never disables
182-// host-key verification, never forces a non-interactive auth bypass, and never
183-// requests a PTY.
184-func sshMuxCommandSpec(route brokerconfig.Route) sshstdio.CommandSpec {
185- spec := sshstdio.BuildCommandForMux(route.Target(), route.Argv()...)
287+// sshMuxCommandSpec builds the local ssh argv for one provisioned route and one
288+// start authorization. It reuses the mux carriage's own command construction
289+// (the option terminator, the POSIX-quoted remote argv, and `-T` for no remote
290+// PTY), splices in only the explicit trust inputs the operator provisioned, and
291+// appends the closed `--daemon-start existing-only|if-needed` word so the remote
292+// helper reads the authorization the broker propagated instead of defaulting.
293+// It never disables host-key verification, never forces a non-interactive auth
294+// bypass, and never requests a PTY.
295+func sshMuxCommandSpec(route brokerconfig.Route, mode ports.BrokerDaemonStartMode) (sshstdio.CommandSpec, error) {
296+ flag, err := brokerDaemonStartArgvFlag(mode)
297+ if err != nil {
298+ return sshstdio.CommandSpec{}, err
299+ }
300+ argv := route.Argv()
301+ for _, word := range argv {
302+ if word == brokerDaemonStartArg {
303+ return sshstdio.CommandSpec{}, fmt.Errorf("vev: broker mux route provisions its own %s", brokerDaemonStartArg)
304+ }
305+ }
306+ argv = append(argv, brokerDaemonStartArg, flag)
307+ spec := sshstdio.BuildCommandForMux(route.Target(), argv...)
186308 options := sshTrustOptions(route)
187309 if len(options) == 0 || len(spec.Args) < 2 {
188- return spec
310+ return spec, nil
189311 }
190312 // BuildCommandForMux returns ["-T", "--", target, remote]; insert options
191313 // after `-T` so they stay ssh options and the option terminator still
@@ -194,7 +316,7 @@ func sshMuxCommandSpec(route brokerconfig.Route) sshstdio.CommandSpec {
194316 args = append(args, spec.Args[0])
195317 args = append(args, options...)
196318 args = append(args, spec.Args[1:]...)
197- return sshstdio.CommandSpec{Path: spec.Path, Args: args}
319+ return sshstdio.CommandSpec{Path: spec.Path, Args: args}, nil
198320 }
199321
200322 // sshTrustOptions renders the provisioned trust inputs as ssh options. A
@@ -229,8 +351,11 @@ func sshTrustOptions(route brokerconfig.Route) []string {
229351 // within the bound and can never pin pool setup. The captured stderr sink is
230352 // read only after that kill and reap, and the sink is itself synchronized, so
231353 // the os/exec copy goroutine can never race the diagnostic.
232-func dialBrokerQUICRoute(ctx context.Context, route brokerconfig.Route, log *slog.Logger) (wire.BoundedTransport, error) {
233- spec := sshMuxCommandSpec(route)
354+func dialBrokerQUICRoute(ctx context.Context, route brokerconfig.Route, mode ports.BrokerDaemonStartMode, log *slog.Logger) (wire.BoundedTransport, error) {
355+ spec, err := sshMuxCommandSpec(route, mode)
356+ if err != nil {
357+ return nil, err
358+ }
234359 stderr := sshstdio.NewDiagnosticSink(0)
235360 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
236361 cmd.Stderr = stderr
@@ -377,21 +502,19 @@ func brokerQUICUnavailable(action string, err error) error {
377502 }
378503
379504 // runBrokerMuxStdioCommand bridges the process' own stdio to the single
380-// provisioned private Unix daemonmux carriage.
505+// provisioned private Unix daemonmux carriage, under the daemon-start
506+// authorization the broker propagated.
381507 func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) error {
382- config, err := loadMuxHelperConfig(options.offlineRoot)
383- if err != nil {
384- return err
385- }
386- route, err := config.LocalMuxRoute()
387- if err != nil {
388- return err
389- }
390- raw, err := ipc.DialMuxContext(ctx, route.Path())
508+ raw, err := dialBrokerMuxHelper(ctx, options)
391509 if err != nil {
392510 return fmt.Errorf("vev: broker mux stdio: dial daemonmux carriage: %w", err)
393511 }
394- return bridgeMuxTransports(ctx, sshstdio.NewStdioTransport(), raw)
512+ bounded, ok := raw.(wire.BoundedTransport)
513+ if !ok {
514+ _ = raw.Close()
515+ return errors.New("vev: broker mux stdio: carriage is not a bounded transport")
516+ }
517+ return bridgeMuxTransports(ctx, sshstdio.NewStdioTransport(), bounded)
395518 }
396519
397520 // runBrokerMuxQUICBootstrapCommand starts one detached `_broker-mux-quic-proxy`,
@@ -401,8 +524,10 @@ func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) err
401524 func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOptions) error {
402525 // Validate the sandbox before spawning anything, so a bad root or config
403526 // fails without a readiness line and without a child.
404- if _, err := loadMuxHelperConfig(options.offlineRoot); err != nil {
405- return err
527+ if !options.production {
528+ if _, err := loadMuxHelperConfig(options.offlineRoot); err != nil {
529+ return err
530+ }
406531 }
407532 exe, err := os.Executable()
408533 if err != nil {
@@ -420,7 +545,19 @@ func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOpti
420545 }
421546 defer func() { _ = devNull.Close() }()
422547
423- cmd := exec.CommandContext(ctx, exe, brokerMuxQUICProxyCommand, "--offline-root", options.offlineRoot)
548+ args := []string{brokerMuxQUICProxyCommand}
549+ if options.production {
550+ args = append(args, "--production")
551+ } else {
552+ args = append(args, "--offline-root", options.offlineRoot)
553+ }
554+ flag, err := brokerDaemonStartArgvFlag(options.startMode)
555+ if err != nil {
556+ _ = writer.Close()
557+ return err
558+ }
559+ args = append(args, brokerDaemonStartArg, flag)
560+ cmd := exec.CommandContext(ctx, exe, args...)
424561 // The proxy writes its readiness through the pipe and its diagnostics
425562 // through stderr; stdio is otherwise detached so the SSH bootstrap channel
426563 // can close while the detached proxy owns the carriage.
@@ -464,14 +601,6 @@ func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOpti
464601 // expiry, or on setup failure, so it can never outlive its advertised
465602 // credential or serve a second physical connection.
466603 func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions) error {
467- config, err := loadMuxHelperConfig(options.offlineRoot)
468- if err != nil {
469- return err
470- }
471- route, err := config.LocalMuxRoute()
472- if err != nil {
473- return err
474- }
475604 server, readiness, err := quic.NewServer()
476605 if err != nil {
477606 return err
@@ -489,11 +618,37 @@ func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions)
489618 return err
490619 }
491620 defer func() { _ = transport.Close() }()
492- raw, err := ipc.DialMuxContext(ctx, route.Path())
621+ raw, err := dialBrokerMuxHelper(ctx, options)
493622 if err != nil {
494623 return fmt.Errorf("vev: broker mux quic proxy: dial daemonmux carriage: %w", err)
495624 }
496- return bridgeMuxTransports(ctx, transport, raw)
625+ bounded, ok := raw.(wire.BoundedTransport)
626+ if !ok {
627+ _ = raw.Close()
628+ return errors.New("vev: broker mux quic proxy: carriage is not a bounded transport")
629+ }
630+ return bridgeMuxTransports(ctx, transport, bounded)
631+}
632+
633+// dialBrokerMuxHelper is a remote-side broker carriage helper, not a client
634+// daemon dial. Production authority is derived locally, never from SSH argv.
635+func dialBrokerMuxHelper(ctx context.Context, options brokerMuxOptions) (daemonmux.RawFramedTransport, error) {
636+ if !options.production {
637+ config, err := loadMuxHelperConfig(options.offlineRoot)
638+ if err != nil {
639+ return nil, err
640+ }
641+ return dialMuxHelperCarriage(ctx, config, options.startMode)
642+ }
643+ route, err := brokerconfig.RouteFromSpec(ports.BrokerRouteSpec{Kind: ports.BrokerRouteUnix, Path: daemonmux.SocketPath(ipc.SocketDir())})
644+ if err != nil {
645+ return nil, err
646+ }
647+ target := ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Policy: localDaemonPolicy(), Address: route.Address(), StartMode: options.startMode}
648+ if err := target.Validate(); err != nil {
649+ return nil, err
650+ }
651+ return dialBrokerLocalDaemon(ctx, route.Path(), target)
497652 }
498653
499654 // bridgeMuxTransports copies raw frames both ways until either carriage ends or
M internal/app/broker_offline_mux_test.go +88 -23
@@ -161,22 +161,60 @@ func writeMuxRoot(t *testing.T, route any) string {
161161 // resolver publishes, so a test can inspect a parsed route without the
162162 // unexported brokerconfig fields.
163163 func loadTestRoute(t *testing.T, route any) brokerconfig.Route {
164+ t.Helper()
165+ _, parsed := loadTestRouteAndConfig(t, route)
166+ return parsed
167+}
168+
169+// loadTestRouteAndConfig loads one sandbox configuration and returns both the
170+// immutable configuration and the route its resolver publishes, so a test can
171+// dial the route the way the broker does: with the whole resolved target.
172+func loadTestRouteAndConfig(t *testing.T, route any) (*brokerconfig.Config, brokerconfig.Route) {
164173 t.Helper()
165174 root := writeMuxRoot(t, route)
166175 layout, err := offlineLayout(root)
167176 require.NoError(t, err)
168177 config, err := brokerconfig.Load(layout)
169178 require.NoError(t, err)
170- resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{
179+ resolved, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{
171180 Purpose: ports.BrokerStreamControl,
181+ Stream: 1,
172182 Endpoint: brokerTestEndpoint,
173183 Registration: brokerTestRegistration(),
174184 Policy: brokerTestPolicy(),
185+ StartMode: ports.BrokerDaemonStartIfNeeded,
175186 })
176187 require.NoError(t, err)
177188 parsed, ok := config.RouteByAddress(resolved.Address)
178189 require.True(t, ok)
179- return parsed
190+ return config, parsed
191+}
192+
193+// testMuxDialTarget resolves the exact dial target the broker publishes for one
194+// provisioned route under one start authorization, so a test dials the route
195+// with the same whole target the pool does.
196+func testMuxDialTarget(t *testing.T, config *brokerconfig.Config, route brokerconfig.Route, mode ports.BrokerDaemonStartMode) ports.BrokerDialTarget {
197+ t.Helper()
198+ resolved, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{
199+ Purpose: ports.BrokerStreamControl,
200+ Stream: 1,
201+ Endpoint: brokerTestEndpoint,
202+ Registration: brokerTestRegistration(),
203+ Policy: brokerTestPolicy(),
204+ StartMode: mode,
205+ })
206+ require.NoError(t, err)
207+ require.Equal(t, route.Address(), resolved.Address, "the test dials the provisioned route")
208+ return resolved
209+}
210+
211+// loadTestRouteTarget loads one sandbox configuration and returns both the
212+// parsed route and the exact resolved dial target the broker would publish for
213+// it, so a test can call dialBrokerRoute exactly as the pool does.
214+func loadTestRouteTarget(t *testing.T, route any) (brokerconfig.Route, ports.BrokerDialTarget) {
215+ t.Helper()
216+ config, parsed := loadTestRouteAndConfig(t, route)
217+ return parsed, testMuxDialTarget(t, config, parsed, ports.BrokerDaemonStartIfNeeded)
180218 }
181219
182220 // TestParseBrokerMuxArgs pins the strict hidden-helper argument contract.
@@ -187,12 +225,17 @@ func TestParseBrokerMuxArgs(t *testing.T) {
187225 wantErr string
188226 want brokerMuxOptions
189227 }{
190- {name: "valid", args: []string{"--offline-root", "/srv/sandbox"}, want: brokerMuxOptions{offlineRoot: "/srv/sandbox"}},
228+ {name: "valid", args: []string{"--offline-root", "/srv/sandbox"}, want: brokerMuxOptions{offlineRoot: "/srv/sandbox", startMode: ports.BrokerDaemonExistingOnly}},
229+ {name: "explicit existing-only", args: []string{"--offline-root", "/srv/sandbox", brokerDaemonStartArg, "existing-only"}, want: brokerMuxOptions{offlineRoot: "/srv/sandbox", startMode: ports.BrokerDaemonExistingOnly}},
230+ {name: "explicit if-needed", args: []string{"--offline-root", "/srv/sandbox", brokerDaemonStartArg, "if-needed"}, want: brokerMuxOptions{offlineRoot: "/srv/sandbox", startMode: ports.BrokerDaemonStartIfNeeded}},
191231 {name: "missing root", args: nil, wantErr: "requires --offline-root"},
192232 {name: "empty root", args: []string{"--offline-root", ""}, wantErr: "requires a path"},
193233 {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate"},
194234 {name: "unknown flag", args: []string{"--idle-grace", "1m"}, wantErr: "unknown flag"},
195235 {name: "positional", args: []string{"--offline-root", "/a", "extra"}, wantErr: "positional"},
236+ {name: "missing daemon-start value", args: []string{"--offline-root", "/a", brokerDaemonStartArg}, wantErr: "requires a value"},
237+ {name: "duplicate daemon-start", args: []string{"--offline-root", "/a", brokerDaemonStartArg, "if-needed", brokerDaemonStartArg, "existing-only"}, wantErr: "duplicate"},
238+ {name: "unknown daemon-start", args: []string{"--offline-root", "/a", brokerDaemonStartArg, "always"}, wantErr: "unknown daemon start mode"},
196239 }
197240 for _, tt := range tests {
198241 t.Run(tt.name, func(t *testing.T) {
@@ -203,7 +246,7 @@ func TestParseBrokerMuxArgs(t *testing.T) {
203246 return
204247 }
205248 require.NoError(t, err)
206- require.Equal(t, brokerMuxOptions{offlineRoot: tt.want.offlineRoot}, command.brokerMux)
249+ require.Equal(t, tt.want, command.brokerMux)
207250 })
208251 }
209252 }
@@ -245,7 +288,8 @@ func TestSSHMuxCommandSpecPreservesTrustAndNoPTY(t *testing.T) {
245288 "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", "/srv/remote"},
246289 "trust": map[string]any{"knownHostsFile": "/etc/ssh/known_hosts", "connectTimeout": "30s"},
247290 })
248- spec := sshMuxCommandSpec(route)
291+ spec, err := sshMuxCommandSpec(route, ports.BrokerDaemonStartIfNeeded)
292+ require.NoError(t, err)
249293 require.Equal(t, "ssh", spec.Path)
250294 require.Equal(t, "-T", spec.Args[0])
251295 require.Contains(t, spec.Args, "--")
@@ -254,6 +298,7 @@ func TestSSHMuxCommandSpecPreservesTrustAndNoPTY(t *testing.T) {
254298 joined := strings.Join(spec.Args, " ")
255299 require.Contains(t, joined, "-- user@host:2222 ")
256300 require.Contains(t, joined, brokerMuxStdioCommand)
301+ require.Contains(t, joined, "'"+brokerDaemonStartArg+"' 'if-needed'")
257302 require.NotContains(t, joined, "StrictHostKeyChecking")
258303 require.NotContains(t, joined, "BatchMode")
259304 require.NotContains(t, joined, "ProxyCommand")
@@ -270,9 +315,25 @@ func TestSSHMuxCommandSpecPreservesTrustAndNoPTY(t *testing.T) {
270315 "target": "host",
271316 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
272317 })
273- plainSpec := sshMuxCommandSpec(plain)
318+ plainSpec, err := sshMuxCommandSpec(plain, ports.BrokerDaemonExistingOnly)
319+ require.NoError(t, err)
274320 require.Equal(t, []string{"-T", "--"}, append([]string(nil), plainSpec.Args[:2]...))
275321 require.NotContains(t, plainSpec.Args, "-o")
322+ require.Contains(t, strings.Join(plainSpec.Args, " "), "'"+brokerDaemonStartArg+"' 'existing-only'")
323+}
324+
325+// TestSSHMuxCommandSpecRefusesUnknownStartMode proves a route never carries an
326+// unreadable start authorization: an unknown mode is refused instead of
327+// defaulting the helper to a permissive one.
328+func TestSSHMuxCommandSpecRefusesUnknownStartMode(t *testing.T) {
329+ isolateSandboxEnv(t)
330+ route := loadTestRoute(t, map[string]any{
331+ "kind": "ssh-stdio",
332+ "target": "host",
333+ "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", "/srv/remote"},
334+ })
335+ _, err := sshMuxCommandSpec(route, ports.BrokerDaemonStartMode(0))
336+ require.Error(t, err)
276337 }
277338
278339 // TestSSHMuxCommandSpecTargetQuotesSpacesInsideArgv proves a remote argv word
@@ -284,8 +345,12 @@ func TestSSHMuxCommandSpecTargetQuotesSpacesInsideArgv(t *testing.T) {
284345 "target": "host",
285346 "argv": []any{"vev", "_broker-mux-stdio", "--offline-root", "/srv/with space"},
286347 })
287- spec := sshMuxCommandSpec(route)
348+ spec, err := sshMuxCommandSpec(route, ports.BrokerDaemonExistingOnly)
349+ require.NoError(t, err)
350+ // The whole remote command stays one quoted argv word, so the space-bearing
351+ // path is still one word and never becomes a second option.
288352 require.Contains(t, spec.Args[len(spec.Args)-1], `'/srv/with space'`)
353+ require.Contains(t, spec.Args[len(spec.Args)-1], "'"+brokerDaemonStartArg+"' 'existing-only'")
289354 }
290355
291356 // TestBrokerQUICPeerAddr pins the host-independent address composition.
@@ -327,11 +392,7 @@ func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) {
327392 connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil)))
328393 require.NoError(t, err)
329394
330- _, err = connector.Connect(context.Background(), ports.BrokerResolvedEndpoint{
331- Identity: brokerTestIdentity,
332- Policy: brokerTestPolicy(),
333- Address: "offline-route-00000000000000000000000000000000",
334- })
395+ _, err = connector.Connect(context.Background(), ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Policy: brokerTestPolicy(), Address: "offline-route-00000000000000000000000000000000", StartMode: ports.BrokerDaemonStartIfNeeded, ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: brokerTestIdentity, Bound: true}})
335396 require.Error(t, err)
336397 var typed ports.BrokerError
337398 require.ErrorAs(t, err, &typed)
@@ -416,14 +477,14 @@ func TestBrokerMuxQUICRejectsBadPinWithoutLeakingSecret(t *testing.T) {
416477 tampered.Fingerprint = strings.Repeat("00", 32)
417478 t.Setenv(fakeSSHReadyEnv, string(mustJSON(t, tampered)))
418479
419- route := loadTestRoute(t, map[string]any{
480+ route, target := loadTestRouteTarget(t, map[string]any{
420481 "kind": "ssh-quic",
421482 "target": "127.0.0.1",
422483 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
423484 })
424485 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
425486 defer cancel()
426- _, err = dialBrokerRoute(ctx, route, slog.New(slog.NewTextHandler(io.Discard, nil)))
487+ _, err = dialBrokerRoute(ctx, route, target, slog.New(slog.NewTextHandler(io.Discard, nil)))
427488 require.Error(t, err)
428489 requireBrokerErrorHasNoReadiness(t, err, readiness)
429490 }
@@ -446,12 +507,12 @@ func TestBrokerMuxQUICRejectsExpiredReadiness(t *testing.T) {
446507 }
447508 t.Setenv(fakeSSHReadyEnv, string(mustJSON(t, readiness)))
448509
449- route := loadTestRoute(t, map[string]any{
510+ route, target := loadTestRouteTarget(t, map[string]any{
450511 "kind": "ssh-quic",
451512 "target": "127.0.0.1",
452513 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
453514 })
454- _, err := dialBrokerRoute(context.Background(), route, slog.New(slog.NewTextHandler(io.Discard, nil)))
515+ _, err := dialBrokerRoute(context.Background(), route, target, slog.New(slog.NewTextHandler(io.Discard, nil)))
455516 require.Error(t, err)
456517 require.NotContains(t, err.Error(), token)
457518 require.NotContains(t, err.Error(), nonce)
@@ -466,7 +527,7 @@ func TestBrokerMuxDialCancellationReapsBootstrap(t *testing.T) {
466527 pidFile := filepath.Join(t.TempDir(), "ssh.pid")
467528 t.Setenv(fakeSSHPIDEnv, pidFile)
468529
469- route := loadTestRoute(t, map[string]any{
530+ route, target := loadTestRouteTarget(t, map[string]any{
470531 "kind": "ssh-quic",
471532 "target": "127.0.0.1",
472533 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
@@ -474,7 +535,7 @@ func TestBrokerMuxDialCancellationReapsBootstrap(t *testing.T) {
474535 ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
475536 defer cancel()
476537 start := time.Now()
477- _, err := dialBrokerRoute(ctx, route, slog.New(slog.NewTextHandler(io.Discard, nil)))
538+ _, err := dialBrokerRoute(ctx, route, target, slog.New(slog.NewTextHandler(io.Discard, nil)))
478539 require.Error(t, err)
479540 require.Less(t, time.Since(start), 3*time.Second, "cancellation must return promptly")
480541
@@ -494,7 +555,7 @@ func TestBrokerMuxDialFloodedStderrIsReapedAndSanitized(t *testing.T) {
494555 pidFile := filepath.Join(t.TempDir(), "ssh.pid")
495556 t.Setenv(fakeSSHPIDEnv, pidFile)
496557
497- route := loadTestRoute(t, map[string]any{
558+ route, target := loadTestRouteTarget(t, map[string]any{
498559 "kind": "ssh-quic",
499560 "target": "127.0.0.1",
500561 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
@@ -504,7 +565,7 @@ func TestBrokerMuxDialFloodedStderrIsReapedAndSanitized(t *testing.T) {
504565 logger := slog.New(slog.NewJSONHandler(&logs, nil))
505566 ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
506567 defer cancel()
507- _, err := dialBrokerRoute(ctx, route, logger)
568+ _, err := dialBrokerRoute(ctx, route, target, logger)
508569 require.Error(t, err)
509570 require.NotContains(t, err.Error(), "flood", "bootstrap stderr must never reach the error")
510571
@@ -535,13 +596,13 @@ func TestBrokerMuxDialStalledBootstrapUsesSetupTimeout(t *testing.T) {
535596 brokerMuxBootstrapReadyTimeout = 250 * time.Millisecond
536597 t.Cleanup(func() { brokerMuxBootstrapReadyTimeout = restore })
537598
538- route := loadTestRoute(t, map[string]any{
599+ route, target := loadTestRouteTarget(t, map[string]any{
539600 "kind": "ssh-quic",
540601 "target": "127.0.0.1",
541602 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
542603 })
543604 start := time.Now()
544- _, err := dialBrokerRoute(context.Background(), route, slog.New(slog.NewTextHandler(io.Discard, nil)))
605+ _, err := dialBrokerRoute(context.Background(), route, target, slog.New(slog.NewTextHandler(io.Discard, nil)))
545606 elapsed := time.Since(start)
546607 require.Error(t, err)
547608 require.Contains(t, err.Error(), "read bootstrap readiness")
@@ -575,7 +636,7 @@ func startMuxBrokerServe(t *testing.T, localRoot string) (string, func()) {
575636 deps, ready := testBrokerServeDeps(newSandboxClock())
576637 ctx, cancel := context.WithCancel(context.Background())
577638 done := runSandbox(ctx, brokerServeOptions{offlineRoot: localRoot}, deps)
578- socketPath := awaitSandboxReady(t, ready)
639+ socketPath := awaitSandboxReady(t, ready, done)
579640 var once bool
580641 stop := func() {
581642 if once {
@@ -597,11 +658,15 @@ func requireBrokerPing(t *testing.T, socketPath string) {
597658 require.NoError(t, err)
598659 defer func() { _ = service.Close() }()
599660
661+ streamID, err := service.NextStreamID()
662+ require.NoError(t, err)
600663 stream, err := service.OpenStream(clientCtx, ports.BrokerOpenStreamRequest{
601664 Purpose: ports.BrokerStreamControl,
665+ Stream: streamID,
602666 Endpoint: brokerTestEndpoint,
603667 Registration: brokerTestRegistration(),
604668 Policy: brokerTestPolicy(),
669+ StartMode: ports.BrokerDaemonStartIfNeeded,
605670 })
606671 require.NoError(t, err)
607672 defer func() { _ = stream.Close() }()
M internal/app/broker_offline_status.go +25 -5
@@ -59,7 +59,8 @@ import (
5959 const (
6060 // brokerLauncherCommand is the hidden intermediate that starts one detached
6161 // `_broker-serve` and exits.
62- brokerLauncherCommand = "_broker-launcher"
62+ brokerLauncherCommand = "_broker-launcher"
63+ productionBrokerLauncherCommand = "_broker-production-launcher"
6364 // brokerStatusCommand is the hidden broker status probe.
6465 brokerStatusCommand = "_broker-status"
6566 )
@@ -160,6 +161,13 @@ func defaultBrokerStatusDeps() brokerStatusDeps {
160161 // parseBrokerLauncherArgs strictly parses
161162 // `_broker-launcher --offline-root ABS [--idle-grace DURATION]`. Unknown flags,
162163 // duplicate flags, positionals, and non-positive durations are refused.
164+func parseProductionBrokerLauncherArgs(args []string) (command, error) {
165+ if len(args) != 0 {
166+ return command{}, usagef("`%s` does not accept arguments", productionBrokerLauncherCommand)
167+ }
168+ return command{kind: kindProductionBrokerLauncher}, nil
169+}
170+
163171 func parseBrokerLauncherArgs(args []string) (command, error) {
164172 var options brokerLauncherOptions
165173 var seenRoot, seenGrace bool
@@ -261,6 +269,10 @@ func parseBrokerStatusArgs(args []string) (command, error) {
261269
262270 // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary
263271 // command and never runs in production flows.
272+func runProductionBrokerLauncherCommand(context.Context) error {
273+ return startDetachedBrokerServe([]string{productionBrokerServeCommand})
274+}
275+
264276 func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error {
265277 return runBrokerLauncher(options)
266278 }
@@ -326,7 +338,19 @@ func startDetachedBrokerServe(args []string) error {
326338 // signal kills a launcher that is still waiting, while killing the launcher
327339 // after it has started the detached `_broker-serve` does not touch that serve --
328340 // it is in its own session and was released, so it survives.
341+func spawnProductionBrokerLauncher(ctx context.Context) error {
342+ return runBrokerLauncherProcess(ctx, []string{productionBrokerLauncherCommand})
343+}
344+
329345 func spawnBrokerLauncher(ctx context.Context, root string, grace time.Duration, graceSet bool) error {
346+ args := []string{brokerLauncherCommand, "--offline-root", root}
347+ if graceSet {
348+ args = append(args, "--idle-grace", grace.String())
349+ }
350+ return runBrokerLauncherProcess(ctx, args)
351+}
352+
353+func runBrokerLauncherProcess(ctx context.Context, args []string) error {
330354 exePath, err := selfExePath()
331355 if err != nil {
332356 return fmt.Errorf("vev: resolving executable path: %w", err)
@@ -337,10 +361,6 @@ func spawnBrokerLauncher(ctx context.Context, root string, grace time.Duration,
337361 }
338362 defer func() { _ = devNull.Close() }()
339363
340- args := []string{brokerLauncherCommand, "--offline-root", root}
341- if graceSet {
342- args = append(args, "--idle-grace", grace.String())
343- }
344364 var stderr bytes.Buffer
345365 cmd := exec.CommandContext(ctx, exePath, args...)
346366 cmd.Env = withoutPerformanceTraceEnv(os.Environ())
M internal/app/broker_offline_status_test.go +3 -4
@@ -296,7 +296,7 @@ func TestBrokerStatusDialOnlyReadyAgainstLiveSandbox(t *testing.T) {
296296 ctx, cancel := context.WithCancel(context.Background())
297297 defer cancel()
298298 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, serveDeps)
299- awaitSandboxReady(t, ready)
299+ awaitSandboxReady(t, ready, done)
300300
301301 deps := defaultBrokerStatusDeps()
302302 var out bytes.Buffer
@@ -306,9 +306,8 @@ func TestBrokerStatusDialOnlyReadyAgainstLiveSandbox(t *testing.T) {
306306 require.Equal(t, "ready", report.Status)
307307 require.Positive(t, report.Epoch)
308308 require.Positive(t, report.Revision)
309- // The sandbox registry restores from its own empty durable store; the
310- // provisioned endpoints feed the resolver, not the host inventory.
311- require.Zero(t, report.HostCount)
309+ // Provisioned endpoints seed durable broker membership before serving.
310+ require.Equal(t, 1, report.HostCount)
312311
313312 awaitSandboxCancel(t, cancel, done)
314313 requireProductionUntouched(t, prodRuntime, prodState)
M internal/app/broker_offline_test.go +15 -8
@@ -309,7 +309,7 @@ func TestBrokerServeRoundTrip(t *testing.T) {
309309 defer cancel()
310310 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)
311311
312- socketPath := awaitSandboxReady(t, ready)
312+ socketPath := awaitSandboxReady(t, ready, done)
313313
314314 clientCtx, clientCancel := context.WithTimeout(context.Background(), brokerTestWait)
315315 defer clientCancel()
@@ -317,11 +317,15 @@ func TestBrokerServeRoundTrip(t *testing.T) {
317317 require.NoError(t, err)
318318 defer func() { _ = service.Close() }()
319319
320+ streamID, err := service.NextStreamID()
321+ require.NoError(t, err)
320322 stream, err := service.OpenStream(clientCtx, ports.BrokerOpenStreamRequest{
321323 Purpose: ports.BrokerStreamControl,
324+ Stream: streamID,
322325 Endpoint: brokerTestEndpoint,
323326 Registration: brokerTestRegistration(),
324327 Policy: policy,
328+ StartMode: ports.BrokerDaemonStartIfNeeded,
325329 })
326330 require.NoError(t, err)
327331 defer func() { _ = stream.Close() }()
@@ -355,14 +359,17 @@ func TestBrokerServeRoundTrip(t *testing.T) {
355359
356360 // awaitSandboxReady waits for the sandbox readiness hook and returns the
357361 // reported socket path.
358-func awaitSandboxReady(t *testing.T, ready <-chan string) string {
362+func awaitSandboxReady(t *testing.T, ready <-chan string, done <-chan error) string {
359363 t.Helper()
360364 select {
361365 case socketPath := <-ready:
362366 require.NotEmpty(t, socketPath)
363367 return socketPath
368+ case err := <-done:
369+ t.Fatalf("sandbox exited before readiness: %v", err)
370+ return ""
364371 case <-time.After(brokerTestWait):
365- t.Fatal("sandbox never reported readiness")
372+ t.Fatal("sandbox never reported readiness and did not exit")
366373 return ""
367374 }
368375 }
@@ -403,7 +410,7 @@ func TestBrokerServeIdleShutdownOnEmptyConfig(t *testing.T) {
403410 defer cancel()
404411 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)
405412
406- socketPath := awaitSandboxReady(t, ready)
413+ socketPath := awaitSandboxReady(t, ready, done)
407414 waitForSandboxShutdown(t, clk, done)
408415
409416 requireSocketRemoved(t, socketPath)
@@ -430,7 +437,7 @@ func TestBrokerServeCancelCleanup(t *testing.T) {
430437 defer cancel()
431438 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)
432439
433- socketPath := awaitSandboxReady(t, ready)
440+ socketPath := awaitSandboxReady(t, ready, done)
434441 awaitSandboxCancel(t, cancel, done)
435442
436443 requireSocketRemoved(t, socketPath)
@@ -474,7 +481,7 @@ func TestBrokerServeShutsDownOnUnexpectedAcceptFailure(t *testing.T) {
474481 defer cancel()
475482 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)
476483
477- awaitSandboxReady(t, ready)
484+ awaitSandboxReady(t, ready, done)
478485 select {
479486 case err := <-done:
480487 require.NoError(t, err)
@@ -496,7 +503,7 @@ func TestBrokerServeRefusesDuplicateOwnership(t *testing.T) {
496503 ctx, cancel := context.WithCancel(context.Background())
497504 defer cancel()
498505 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)
499- socketPath := awaitSandboxReady(t, ready)
506+ socketPath := awaitSandboxReady(t, ready, done)
500507
501508 // A second foreground owner fails fast on the lifetime lock.
502509 secondDeps, _ := testBrokerServeDeps(newSandboxClock())
@@ -554,7 +561,7 @@ func newBrokerMuxFixture(t *testing.T, policy ports.BrokerPolicy) *brokerMuxFixt
554561 for i := range incarnation {
555562 incarnation[i] = byte(0x10 + i)
556563 }
557- binding, err := daemonmux.NewServerBinding(ports.BrokerDaemonIdentity(brokerTestIdentity), incarnation, policy)
564+ binding, err := daemonmux.NewServerBindings(ports.BrokerDaemonIdentity(brokerTestIdentity), incarnation, []daemonmux.ServerPolicyAdmission{{Policy: policy, Origin: ports.SessionOriginRemote}})
558565 require.NoError(t, err)
559566 supervisor, err := daemonmux.NewServerSupervisor(aggregate, binding, daemonmux.DefaultMuxCeilings(), 8)
560567 require.NoError(t, err)
M internal/app/broker_production.go +24 -0
@@ -6,6 +6,9 @@ import (
66 "github.com/bnema/vev/internal/adapters/brokerconfig"
77 "github.com/bnema/vev/internal/adapters/ipc"
88 "github.com/bnema/vev/internal/platform"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/catalogue"
912 )
1013
1114 // productionBrokerLayout is the single production broker filesystem contract.
@@ -15,6 +18,27 @@ func productionBrokerConfigPath() string {
1518 return filepath.Join(filepath.Dir(platform.ConfigPath()), "broker.json")
1619 }
1720
21+func remoteBrokerPolicy(transport string) ports.BrokerPolicy {
22+ if transport == "stdio" {
23+ transport = "ssh-stdio"
24+ } else {
25+ transport = "ssh-quic"
26+ }
27+ return ports.BrokerPolicy{
28+ ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
29+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: transport,
30+ Trust: "openssh-config-v1", Launch: "explicit", Isolation: "per-user",
31+ }
32+}
33+
34+func localDaemonPolicy() ports.BrokerPolicy {
35+ return ports.BrokerPolicy{
36+ ProtocolVersion: protocol.Version, CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
37+ EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned, Transport: "unix-mux",
38+ Trust: "same-user", Launch: "explicit", Isolation: "per-user",
39+ }
40+}
41+
1842 func productionBrokerLayout() brokerconfig.Layout {
1943 root := filepath.Join(platform.StateDir(), "broker")
2044 runtime := filepath.Join(ipc.SocketDir(), "broker")
A internal/app/broker_production_connect.go +61 -0
@@ -0,0 +1,61 @@
1+package app
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/adapters/brokeripc"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+const productionBrokerStartupTimeout = 10 * time.Second
13+
14+// connectProductionBroker connects to the sole per-user production broker.
15+// Process election and idle lifetime remain owned by the existing broker
16+// launcher and broker.Supervisor; callers receive only the semantic façade.
17+func connectProductionBroker(ctx context.Context) (ports.BrokerService, error) {
18+ layout := productionBrokerLayout()
19+ request := brokerStatusRequest{
20+ layout: layout, socketPath: brokeripc.SocketPath(layout.Runtime),
21+ deadline: time.Now().Add(productionBrokerStartupTimeout), timeout: productionBrokerStartupTimeout,
22+ root: layout.Root,
23+ }
24+ deps := defaultBrokerStatusDeps()
25+ deps.spawn = func(ctx context.Context, _ string, _ time.Duration, _ bool) error {
26+ return spawnProductionBrokerLauncher(ctx)
27+ }
28+ if _, err := ensureBrokerReady(ctx, request, deps); err != nil {
29+ return nil, fmt.Errorf("vev: ensure broker: %w", err)
30+ }
31+ service, err := brokeripc.NewConnector(request.socketPath, brokeripc.Config{}).Connect(ctx)
32+ if err != nil {
33+ return nil, fmt.Errorf("vev: connect broker: %w", err)
34+ }
35+ if err := awaitBrokerPublication(ctx, service); err != nil {
36+ _ = service.Close()
37+ return nil, fmt.Errorf("vev: await broker publication: %w", err)
38+ }
39+ return service, nil
40+}
41+
42+func awaitBrokerPublication(ctx context.Context, service ports.BrokerService) error {
43+ sub, err := service.Subscribe()
44+ if err != nil {
45+ return err
46+ }
47+ defer sub.Close()
48+ for {
49+ snapshot := service.Snapshot()
50+ if snapshot.Epoch != 0 {
51+ return snapshot.Validate()
52+ }
53+ select {
54+ case <-ctx.Done():
55+ return ctx.Err()
56+ case <-service.Done():
57+ return service.Err()
58+ case <-sub.Changed():
59+ }
60+ }
61+}
M internal/app/broker_production_test.go +16 -0
@@ -7,6 +7,22 @@ import (
77 "github.com/stretchr/testify/require"
88 )
99
10+func TestProductionBrokerHiddenCommandsRejectSandboxOptions(t *testing.T) {
11+ serve, err := parseArgs([]string{productionBrokerServeCommand})
12+ require.NoError(t, err)
13+ require.Equal(t, kindProductionBrokerServe, serve.kind)
14+ require.True(t, serve.brokerServe.production)
15+
16+ launcher, err := parseArgs([]string{productionBrokerLauncherCommand})
17+ require.NoError(t, err)
18+ require.Equal(t, kindProductionBrokerLauncher, launcher.kind)
19+
20+ for _, name := range []string{productionBrokerServeCommand, productionBrokerLauncherCommand} {
21+ _, err = parseArgs([]string{name, "--offline-root", t.TempDir()})
22+ require.Error(t, err)
23+ }
24+}
25+
1026 func TestProductionBrokerLayoutUsesDedicatedRuntimeStateAndConfig(t *testing.T) {
1127 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
1228 t.Setenv("XDG_STATE_HOME", t.TempDir())
A internal/app/broker_ready.go +301 -0
@@ -0,0 +1,301 @@
1+package app
2+
3+import (
4+ "context"
5+ "encoding/hex"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "io/fs"
11+ "os"
12+ "path/filepath"
13+ "strconv"
14+ "time"
15+
16+ "github.com/bnema/vev/internal/adapters/brokerconfig"
17+ "github.com/bnema/vev/internal/adapters/brokeripc"
18+ "github.com/bnema/vev/internal/adapters/ipc"
19+ "github.com/bnema/vev/internal/domain"
20+ "github.com/bnema/vev/internal/ports"
21+ "github.com/bnema/vev/internal/protocol"
22+)
23+
24+const brokerReadyCommand = "_broker-ready"
25+
26+type brokerReadyOptions struct {
27+ offlineRoot string
28+ require string
29+ timeout time.Duration
30+ maxAge time.Duration
31+}
32+
33+type brokerReadyResult struct {
34+ Schema string `json:"schema"`
35+ Status string `json:"status"`
36+ Require string `json:"require"`
37+ Reason string `json:"reason"`
38+ Pending []string `json:"pending"`
39+ Epoch string `json:"epoch"`
40+ Revision string `json:"revision"`
41+ Local *brokerReadyLocal `json:"local"`
42+}
43+type brokerReadyLocal struct {
44+ Identity string `json:"identity"`
45+ Incarnation string `json:"incarnation"`
46+ Version string `json:"version"`
47+ Availability string `json:"availability"`
48+ InventoryKnown bool `json:"inventory_known"`
49+ LastSuccess string `json:"last_success"`
50+}
51+
52+func parseBrokerReadyArgs(args []string) (brokerReadyOptions, error) {
53+ o := brokerReadyOptions{timeout: 10 * time.Second, maxAge: 5 * time.Second}
54+ seen := map[string]bool{}
55+ for i := 0; i < len(args); i += 2 {
56+ if i+1 >= len(args) {
57+ return o, usagef("`%s` requires a value", args[i])
58+ }
59+ name, value := args[i], args[i+1]
60+ if seen[name] {
61+ return o, usagef("duplicate `%s`", name)
62+ }
63+ seen[name] = true
64+ switch name {
65+ case "--offline-root":
66+ o.offlineRoot = value
67+ case "--require":
68+ o.require = value
69+ case "--timeout":
70+ d, err := time.ParseDuration(value)
71+ if err != nil || d <= 0 {
72+ return o, usagef("`--timeout` must be positive")
73+ }
74+ o.timeout = d
75+ case "--max-observation-age":
76+ d, err := time.ParseDuration(value)
77+ if err != nil || d <= 0 {
78+ return o, usagef("`--max-observation-age` must be positive")
79+ }
80+ o.maxAge = d
81+ default:
82+ return o, usagef("unknown `%s` option", name)
83+ }
84+ }
85+ if o.require != "local-authority" && o.require != "local-catalogue" {
86+ return o, usagef("`--require` must be local-authority or local-catalogue")
87+ }
88+ if o.require != "local-catalogue" && seen["--max-observation-age"] {
89+ return o, usagef("`--max-observation-age` is only valid for local-catalogue")
90+ }
91+ if o.offlineRoot != "" && (!filepath.IsAbs(o.offlineRoot) || filepath.Clean(o.offlineRoot) != o.offlineRoot) {
92+ return o, usagef("`--offline-root` must be an absolute clean path")
93+ }
94+ return o, nil
95+}
96+
97+var brokerReadyDial = func(ctx context.Context, path string) (ports.BrokerService, error) {
98+ return brokeripc.Dial(ctx, path, brokeripc.Config{})
99+}
100+
101+func runBrokerReady(ctx context.Context, o brokerReadyOptions, out io.Writer) error {
102+ deadline, cancel := context.WithTimeout(ctx, o.timeout)
103+ defer cancel()
104+ path := brokeripc.SocketPath(productionBrokerLayout().Runtime)
105+ if o.offlineRoot != "" {
106+ layout, err := brokerconfig.ResolveLayout(o.offlineRoot, []string{productionBrokerLayout().Runtime, productionBrokerLayout().State})
107+ if err != nil {
108+ return writeReady(out, o, "terminal", "config", nil, 4)
109+ }
110+ path = brokeripc.SocketPath(layout.Runtime)
111+ }
112+ backoff := 5 * time.Millisecond
113+ for {
114+ if err := deadline.Err(); err != nil {
115+ if errors.Is(ctx.Err(), context.Canceled) {
116+ return writeReady(out, o, "canceled", "canceled", nil, 5)
117+ }
118+ return writeReady(out, o, "timeout", "deadline", nil, 3)
119+ }
120+ // A path that exists but is not a Unix socket, and a path this process
121+ // may not inspect, are terminal security failures: retrying them into
122+ // the deadline would report a foreign path as a slow broker. Only
123+ // proven absence is retried, which is what lets an on-demand broker
124+ // still come up under this probe.
125+ if reason, terminal := brokerReadyEndpointSecurity(path); terminal {
126+ return writeReady(out, o, "terminal", reason, nil, 4)
127+ }
128+ service, err := brokerReadyDial(deadline, path)
129+ if err != nil {
130+ if reason, terminal := terminalReadyReason(err); terminal {
131+ return writeReady(out, o, "terminal", reason, nil, 4)
132+ }
133+ select {
134+ case <-deadline.Done():
135+ continue
136+ case <-time.After(backoff):
137+ }
138+ if backoff < 50*time.Millisecond {
139+ backoff *= 2
140+ if backoff > 50*time.Millisecond {
141+ backoff = 50 * time.Millisecond
142+ }
143+ }
144+ continue
145+ }
146+ result, reconnect := observeBrokerReady(deadline, o, service)
147+ _ = service.Close()
148+ if result != nil {
149+ return emitReady(out, *result)
150+ }
151+ if !reconnect {
152+ continue
153+ }
154+ }
155+}
156+
157+func observeBrokerReady(ctx context.Context, o brokerReadyOptions, service ports.BrokerService) (*brokerReadyResult, bool) {
158+ sub, err := service.Subscribe()
159+ if err != nil {
160+ r := readyResult(o, "terminal", "protocol", nil)
161+ return &r, false
162+ }
163+ defer sub.Close()
164+ for {
165+ snapshot := service.Snapshot()
166+ if snapshot.Epoch != 0 {
167+ if snapshot.Validate() != nil {
168+ r := readyResult(o, "terminal", "protocol", nil)
169+ return &r, false
170+ }
171+ locals := make([]ports.BrokerDaemonObservation, 0, 1)
172+ for _, d := range snapshot.Daemons {
173+ if d.Local {
174+ locals = append(locals, d)
175+ }
176+ }
177+ if len(locals) > 1 {
178+ r := readyResult(o, "terminal", "protocol", nil)
179+ return &r, false
180+ }
181+ if len(locals) == 1 {
182+ r := evaluateReady(o, snapshot, locals[0])
183+ if r.Status == "ready" || r.Status == "terminal" {
184+ return &r, false
185+ }
186+ }
187+ }
188+ select {
189+ case <-ctx.Done():
190+ return nil, false
191+ case <-service.Done():
192+ return nil, true
193+ case <-sub.Changed():
194+ }
195+ }
196+}
197+
198+func evaluateReady(o brokerReadyOptions, s ports.BrokerSnapshot, local ports.BrokerDaemonObservation) brokerReadyResult {
199+ r := readyResult(o, "pending", "local-not-ready", &local)
200+ r.Epoch = strconv.FormatUint(uint64(s.Epoch), 10)
201+ r.Revision = strconv.FormatUint(uint64(s.Revision), 10)
202+ if o.require == "local-authority" {
203+ r.Status = "ready"
204+ r.Reason = "authority"
205+ r.Pending = []string{}
206+ return r
207+ }
208+ valid := local.Availability == domain.RemoteAvailabilityReachable && local.InventoryKnown && local.Identity.Validate() == nil && local.Incarnation.Validate() == nil && local.ProtocolVersion == protocol.Version && !local.LastSuccess.IsZero()
209+ age := time.Since(local.LastSuccess)
210+ valid = valid && age >= 0 && age <= o.maxAge
211+ if valid {
212+ r.Status = "ready"
213+ r.Reason = "catalogue"
214+ r.Pending = []string{}
215+ } else {
216+ r.Pending = []string{"local-catalogue"}
217+ }
218+ return r
219+}
220+
221+func readyResult(o brokerReadyOptions, status, reason string, local *ports.BrokerDaemonObservation) brokerReadyResult {
222+ r := brokerReadyResult{Schema: "vev.broker-ready/v1", Status: status, Require: o.require, Reason: reason, Pending: []string{o.require}, Epoch: "0", Revision: "0"}
223+ if local != nil {
224+ r.Local = &brokerReadyLocal{Identity: string(local.Identity), Incarnation: hex.EncodeToString(local.Incarnation[:]), Version: strconv.FormatUint(uint64(local.ProtocolVersion), 10), Availability: local.Availability.String(), InventoryKnown: local.InventoryKnown, LastSuccess: local.LastSuccess.UTC().Format(time.RFC3339Nano)}
225+ }
226+ return r
227+}
228+
229+// brokerReadyEndpointSecurity classifies the selected endpoint path before any
230+// dial. Absence stays retryable; a path that exists and is not a Unix socket,
231+// and a path this process may not inspect, are terminal security failures. Only
232+// the specific not-exist outcome is absence, so an unreadable parent, a foreign
233+// path, or any other inspection failure fails closed.
234+func brokerReadyEndpointSecurity(path string) (string, bool) {
235+ info, err := os.Lstat(path)
236+ switch {
237+ case errors.Is(err, fs.ErrNotExist):
238+ return "", false
239+ case err != nil:
240+ return "security", true
241+ case info.Mode()&os.ModeSocket == 0:
242+ return "security", true
243+ default:
244+ return "", false
245+ }
246+}
247+
248+// terminalReadyReason classifies one failed dial attempt that must never be
249+// retried into the readiness deadline. A refusal that proves the path, the peer,
250+// the credentials, the configuration, or the broker conversation itself is not
251+// the expected broker is terminal; every other outcome, including an absent or
252+// refused socket, stays retryable so a broker can still come up.
253+func terminalReadyReason(err error) (string, bool) {
254+ switch {
255+ case err == nil:
256+ return "", false
257+ case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
258+ return "", false
259+ case errors.Is(err, ipc.ErrMuxPath), errors.Is(err, ipc.ErrMuxForeignPath),
260+ errors.Is(err, ipc.ErrMuxPeerRejected), errors.Is(err, ipc.ErrMuxUnsupported),
261+ errors.Is(err, os.ErrPermission):
262+ return "security", true
263+ case errors.Is(err, brokeripc.ErrConfig):
264+ return "config", true
265+ case errors.Is(err, brokeripc.ErrProtocol), errors.Is(err, brokeripc.ErrMalformedFrame),
266+ errors.Is(err, ipc.ErrFrameTooLarge), errors.Is(err, ipc.ErrZeroLengthFrame):
267+ return "protocol", true
268+ default:
269+ return "", false
270+ }
271+}
272+
273+func emitReady(w io.Writer, r brokerReadyResult) error {
274+ b, err := json.Marshal(r)
275+ if err != nil || len(b)+1 > 4096 {
276+ return &exitCoded{code: 1, err: errors.New("broker readiness output failure")}
277+ }
278+ if _, err = w.Write(append(b, '\n')); err != nil {
279+ return &exitCoded{code: 1, err: errors.New("broker readiness output failure")}
280+ }
281+ switch r.Status {
282+ case "ready":
283+ return nil
284+ case "timeout":
285+ return &exitCoded{code: 3, err: errors.New("broker readiness timeout")}
286+ case "terminal":
287+ return &exitCoded{code: 4, err: errors.New("broker readiness terminal failure")}
288+ case "canceled":
289+ return &exitCoded{code: 5, err: context.Canceled}
290+ default:
291+ return &exitCoded{code: 3, err: errors.New("broker readiness timeout")}
292+ }
293+}
294+func writeReady(w io.Writer, o brokerReadyOptions, status, reason string, local *ports.BrokerDaemonObservation, code int) error {
295+ r := readyResult(o, status, reason, local)
296+ err := emitReady(w, r)
297+ if code != 0 {
298+ return &exitCoded{code: code, err: fmt.Errorf("broker readiness %s", reason)}
299+ }
300+ return err
301+}
A internal/app/broker_ready_subprocess_test.go +352 -0
@@ -0,0 +1,352 @@
1+package app
2+
3+// Real-subprocess coverage for the hidden `_broker-ready` probe.
4+//
5+// These tests re-execute the test binary through the production dispatch path
6+// (TestMain intercepts the hidden broker commands when brokerHelperEnv is set),
7+// so argument parsing, endpoint classification, the real broker IPC setup, the
8+// readiness evaluation, the JSON document, and the process exit code are all
9+// exercised exactly as an operator would observe them. A real `_broker-serve`
10+// composes the offline broker over one isolated root, and the probe reads it
11+// over a real AF_UNIX carriage.
12+
13+import (
14+ "encoding/json"
15+ "errors"
16+ "fmt"
17+ "net"
18+ "os"
19+ "os/exec"
20+ "path/filepath"
21+ "strings"
22+ "syscall"
23+ "testing"
24+ "time"
25+
26+ "github.com/stretchr/testify/require"
27+
28+ "github.com/bnema/vev/internal/adapters/brokerconfig"
29+ "github.com/bnema/vev/internal/adapters/brokeripc"
30+)
31+
32+// readySubprocessSandbox isolates one real offline root and records every
33+// spawned broker helper, exactly like the status subprocess sandbox.
34+type readySubprocessSandbox struct {
35+ t *testing.T
36+ root string
37+ layout brokerconfig.Layout
38+ recordDir string
39+ prodRuntime string
40+ prodState string
41+ broker *exec.Cmd
42+}
43+
44+// newReadySubprocessSandbox writes one marker-valid empty sandbox and arranges
45+// cleanup of every recorded broker process.
46+func newReadySubprocessSandbox(t *testing.T) *readySubprocessSandbox {
47+ t.Helper()
48+ prodRuntime, prodState := t.TempDir(), t.TempDir()
49+ t.Setenv("XDG_RUNTIME_DIR", prodRuntime)
50+ t.Setenv("XDG_STATE_HOME", prodState)
51+ root := filepath.Join(shortTempDir(t, "vevr"), "s")
52+ require.NoError(t, os.MkdirAll(root, 0o700))
53+ writeSandboxConfig(t, root, sandboxEmptyDocument("3s"))
54+ recordDir := filepath.Join(shortTempDir(t, "vevw"), "r")
55+ require.NoError(t, os.MkdirAll(recordDir, 0o700))
56+ t.Setenv(brokerHelperEnv, "1")
57+ t.Setenv(brokerHelperRecordDirEnv, recordDir)
58+ layout, err := offlineLayout(root)
59+ require.NoError(t, err)
60+
61+ sandbox := &readySubprocessSandbox{t: t, root: root, layout: layout, recordDir: recordDir, prodRuntime: prodRuntime, prodState: prodState}
62+ t.Cleanup(sandbox.cleanup)
63+ return sandbox
64+}
65+
66+// socketPath is the broker endpoint inside the sandbox runtime directory.
67+func (s *readySubprocessSandbox) socketPath() string {
68+ return brokeripc.SocketPath(s.layout.Runtime)
69+}
70+
71+// ready runs `_broker-ready` for this sandbox with the supplied extra flags and
72+// returns its stdout, stderr, and exit code.
73+func (s *readySubprocessSandbox) ready(timeout string, extra ...string) (string, string, int) {
74+ s.t.Helper()
75+ args := append([]string{brokerReadyCommand, "--offline-root", s.root, "--timeout", timeout}, extra...)
76+ return runBrokerCommand(s.t, args...)
77+}
78+
79+// startBroker runs one foreground `_broker-serve` helper in the background.
80+func (s *readySubprocessSandbox) startBroker() {
81+ s.t.Helper()
82+ command := exec.Command(os.Args[0], brokerServeCommand, "--offline-root", s.root)
83+ command.Env = os.Environ()
84+ command.Stdout = os.Stdout
85+ command.Stderr = os.Stderr
86+ require.NoError(s.t, command.Start())
87+ s.broker = command
88+}
89+
90+// waitForBrokerSocket waits until the broker has bound its endpoint.
91+func (s *readySubprocessSandbox) waitForBrokerSocket(timeout time.Duration) bool {
92+ s.t.Helper()
93+ deadline := time.Now().Add(timeout)
94+ for time.Now().Before(deadline) {
95+ if info, err := os.Lstat(s.socketPath()); err == nil && info.Mode()&os.ModeSocket != 0 {
96+ return true
97+ }
98+ time.Sleep(10 * time.Millisecond)
99+ }
100+ return false
101+}
102+
103+// cleanup terminates the background broker and every recorded serve process so
104+// no test leaks an orphan.
105+func (s *readySubprocessSandbox) cleanup() {
106+ if s.broker != nil && s.broker.Process != nil {
107+ _ = s.broker.Process.Signal(syscall.SIGTERM)
108+ done := make(chan struct{})
109+ go func() { _ = s.broker.Wait(); close(done) }()
110+ select {
111+ case <-done:
112+ case <-time.After(10 * time.Second):
113+ _ = s.broker.Process.Kill()
114+ <-done
115+ }
116+ }
117+ for _, pid := range recordedPIDsIn(s.t, s.recordDir, brokerServeCommand) {
118+ if err := syscall.Kill(pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
119+ continue
120+ }
121+ _ = waitForProcessExit(pid, 10*time.Second)
122+ }
123+}
124+
125+// readyDoc is the bounded readiness report the probe prints.
126+type readyDoc struct {
127+ Schema string `json:"schema"`
128+ Status string `json:"status"`
129+ Require string `json:"require"`
130+ Reason string `json:"reason"`
131+ Pending []string `json:"pending"`
132+ Epoch string `json:"epoch"`
133+}
134+
135+// decodeReadyReport decodes exactly one bounded JSON report from stdout.
136+func decodeReadyReport(t *testing.T, raw string) readyDoc {
137+ t.Helper()
138+ trimmed := strings.TrimSpace(raw)
139+ require.Equal(t, 1, strings.Count(trimmed, "\n")+1, "the probe prints exactly one report line")
140+ var report readyDoc
141+ require.NoError(t, json.Unmarshal([]byte(trimmed), &report))
142+ require.Equal(t, "vev.broker-ready/v1", report.Schema)
143+ return report
144+}
145+
146+// TestBrokerReadySubprocessReadsARealBrokerAuthority proves the positive path
147+// end to end: a real `_broker-serve` over an isolated root publishes its local
148+// daemon entry, `--require local-authority` is satisfied with exit zero and the
149+// broker epoch, and `--require local-catalogue` stays pending because the
150+// provisioned local route is absent, so it is never falsely reported ready.
151+func TestBrokerReadySubprocessReadsARealBrokerAuthority(t *testing.T) {
152+ sandbox := newReadySubprocessSandbox(t)
153+ // One provisioned local binding whose route nothing listens on: the broker
154+ // publishes its local entry as configured authority, so `local-authority` is
155+ // observable while the catalogue is not.
156+ require.NoError(t, os.WriteFile(filepath.Join(sandbox.root, brokerconfig.ConfigFileName),
157+ []byte(fmt.Sprintf(`{"marker":%q,"registrations":[],"local":{"identity":"subprocess-local","displayOrigin":"local","route":%q,"policy":{"protocolVersion":%d,"catalogSchemaVersion":3,"environmentPolicy":"client-owned","transport":"unix-mux","trust":"same-user","launch":"explicit","isolation":"per-user"}}}`,
158+ brokerconfig.Marker, filepath.Join(sandbox.root, "absent-mux.sock"), readyTestPolicy().ProtocolVersion)), 0o600))
159+ layout, err := offlineLayout(sandbox.root)
160+ require.NoError(t, err)
161+ sandbox.layout = layout
162+
163+ sandbox.startBroker()
164+ require.True(t, sandbox.waitForBrokerSocket(15*time.Second), "the broker must bind its endpoint")
165+
166+ stdout, stderr, code := sandbox.ready("5s", "--require", "local-authority")
167+ require.Equal(t, 0, code, "stderr: %s", stderr)
168+ authority := decodeReadyReport(t, stdout)
169+ require.Equal(t, "ready", authority.Status)
170+ require.Equal(t, "authority", authority.Reason)
171+ require.Equal(t, []string{}, authority.Pending)
172+ require.Equal(t, "local-authority", authority.Require)
173+ require.NotEqual(t, "0", authority.Epoch, "a live broker reports its own epoch")
174+ require.NotEmpty(t, strings.TrimSpace(stdout))
175+
176+ stdout, stderr, code = sandbox.ready("5s", "--require", "local-catalogue")
177+ require.Equal(t, 3, code, "stderr: %s", stderr)
178+ catalogue := decodeReadyReport(t, stdout)
179+ require.Equal(t, "timeout", catalogue.Status)
180+ require.Equal(t, []string{"local-catalogue"}, catalogue.Pending)
181+ require.NotEqual(t, "ready", catalogue.Status, "an unobserved catalogue is never reported ready")
182+
183+ requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState)
184+}
185+
186+// TestBrokerReadySubprocessStaleSocketIsNeverReady proves a socket file left by
187+// a dead owner is retried into the deadline: the probe reports timeout, never a
188+// false ready, and never leaves a second socket behind.
189+func TestBrokerReadySubprocessStaleSocketIsNeverReady(t *testing.T) {
190+ sandbox := newReadySubprocessSandbox(t)
191+ require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700))
192+ bindStaleUnixSocket(t, sandbox.socketPath())
193+
194+ start := time.Now()
195+ stdout, _, code := sandbox.ready("500ms", "--require", "local-authority")
196+ require.Equal(t, 3, code)
197+ report := decodeReadyReport(t, stdout)
198+ require.Equal(t, "timeout", report.Status)
199+ require.Equal(t, "deadline", report.Reason)
200+ require.Less(t, time.Since(start), 10*time.Second, "the probe is bounded by its own timeout")
201+
202+ // A stale socket is recoverable absence, so nothing was started over it: the
203+ // path is still the same dead socket file and no helper process ran.
204+ info, err := os.Lstat(sandbox.socketPath())
205+ require.NoError(t, err)
206+ require.NotZero(t, info.Mode()&os.ModeSocket)
207+ require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand))
208+ requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState)
209+}
210+
211+// TestBrokerReadySubprocessBlockedHandshakeIsNeverReady proves a live endpoint
212+// that never answers the broker preamble is never reported ready: the probe
213+// stays inside its own bound and reports timeout.
214+func TestBrokerReadySubprocessBlockedHandshakeIsNeverReady(t *testing.T) {
215+ sandbox := newReadySubprocessSandbox(t)
216+ require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700))
217+ // A listener that accepts and never answers: the broker preamble stalls.
218+ _ = bindUnixSocket(t, sandbox.socketPath())
219+
220+ start := time.Now()
221+ stdout, _, code := sandbox.ready("700ms", "--require", "local-authority")
222+ require.Equal(t, 3, code)
223+ report := decodeReadyReport(t, stdout)
224+ require.Equal(t, "timeout", report.Status)
225+ require.NotEqual(t, "ready", report.Status, "a stalled handshake is never a false ready")
226+ require.Less(t, time.Since(start), 10*time.Second)
227+
228+ // A stalled-but-live endpoint is never respawned over.
229+ require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand))
230+ requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState)
231+}
232+
233+// TestBrokerReadySubprocessNonSocketPathIsTerminal proves a path that exists but
234+// is not a Unix socket is a terminal security failure (exit 4) rather than a
235+// retryable absence, for both a regular file and a directory.
236+func TestBrokerReadySubprocessNonSocketPathIsTerminal(t *testing.T) {
237+ tests := []struct {
238+ name string
239+ place func(t *testing.T, socketPath string)
240+ }{
241+ {
242+ name: "regular file",
243+ place: func(t *testing.T, socketPath string) {
244+ require.NoError(t, os.WriteFile(socketPath, []byte("not a socket"), 0o600))
245+ },
246+ },
247+ {
248+ name: "directory",
249+ place: func(t *testing.T, socketPath string) {
250+ require.NoError(t, os.MkdirAll(socketPath, 0o700))
251+ },
252+ },
253+ }
254+ for _, tt := range tests {
255+ t.Run(tt.name, func(t *testing.T) {
256+ sandbox := newReadySubprocessSandbox(t)
257+ require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700))
258+ tt.place(t, sandbox.socketPath())
259+
260+ stdout, stderr, code := sandbox.ready("500ms", "--require", "local-authority")
261+ require.Equal(t, 4, code, "stderr: %s", stderr)
262+ report := decodeReadyReport(t, stdout)
263+ require.Equal(t, "terminal", report.Status)
264+ require.Equal(t, "security", report.Reason)
265+ require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand))
266+ })
267+ }
268+}
269+
270+// TestBrokerReadySubprocessPermissionRefusalIsTerminal proves a path this
271+// process may not inspect is a terminal security failure with exit 4, and never
272+// a timeout or an absence that would invite a spawn.
273+func TestBrokerReadySubprocessPermissionRefusalIsTerminal(t *testing.T) {
274+ if os.Geteuid() == 0 {
275+ t.Skip("an unreadable directory does not deny root")
276+ }
277+ sandbox := newReadySubprocessSandbox(t)
278+ require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700))
279+ require.NoError(t, os.Chmod(sandbox.layout.Runtime, 0o000))
280+ t.Cleanup(func() { _ = os.Chmod(sandbox.layout.Runtime, 0o700) })
281+
282+ stdout, stderr, code := sandbox.ready("500ms", "--require", "local-authority")
283+ require.Equal(t, 4, code, "stderr: %s", stderr)
284+ report := decodeReadyReport(t, stdout)
285+ require.Equal(t, "terminal", report.Status)
286+ require.Equal(t, "security", report.Reason)
287+ require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand))
288+}
289+
290+// TestBrokerReadySubprocessAbsentSocketTimesOut proves a missing endpoint is
291+// retried into the deadline and reported as a timeout with no broker spawned.
292+func TestBrokerReadySubprocessAbsentSocketTimesOut(t *testing.T) {
293+ sandbox := newReadySubprocessSandbox(t)
294+ _, statErr := os.Lstat(sandbox.socketPath())
295+ require.ErrorIs(t, statErr, os.ErrNotExist)
296+
297+ stdout, _, code := sandbox.ready("400ms", "--require", "local-authority")
298+ require.Equal(t, 3, code)
299+ report := decodeReadyReport(t, stdout)
300+ require.Equal(t, "timeout", report.Status)
301+ require.Equal(t, "deadline", report.Reason)
302+ require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand), "the readiness probe never ensures or spawns")
303+}
304+
305+// TestBrokerReadySubprocessNonBrokerEndpointIsTerminal proves a live endpoint
306+// that answers the broker setup with foreign framing is a terminal protocol
307+// failure (exit 4) rather than a retryable absence, so a socket that is not the
308+// expected broker is never waited out as if it were merely slow.
309+func TestBrokerReadySubprocessNonBrokerEndpointIsTerminal(t *testing.T) {
310+ sandbox := newReadySubprocessSandbox(t)
311+ require.NoError(t, os.MkdirAll(sandbox.layout.Runtime, 0o700))
312+ listener := bindUnixSocket(t, sandbox.socketPath())
313+ go answerWithForeignBytes(listener)
314+
315+ stdout, stderr, code := sandbox.ready("700ms", "--require", "local-authority")
316+ require.Equal(t, 4, code, "stderr: %s", stderr)
317+ report := decodeReadyReport(t, stdout)
318+ require.Equal(t, "terminal", report.Status)
319+ require.Equal(t, "protocol", report.Reason)
320+ require.Empty(t, recordedPIDsIn(t, sandbox.recordDir, brokerServeCommand))
321+ requireProductionUntouched(t, sandbox.prodRuntime, sandbox.prodState)
322+}
323+
324+// answerWithForeignBytes serves one connection per accept with a bounded
325+// non-framed answer, standing in for a Unix socket that is not a broker.
326+func answerWithForeignBytes(listener net.Listener) {
327+ for {
328+ conn, err := listener.Accept()
329+ if err != nil {
330+ return
331+ }
332+ go func() {
333+ defer func() { _ = conn.Close() }()
334+ buf := make([]byte, 4096)
335+ _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
336+ _, _ = conn.Read(buf)
337+ _, _ = conn.Write([]byte("this endpoint is not a vev broker at all........."))
338+ }()
339+ }
340+}
341+
342+// TestBrokerReadySubprocessArgumentRefusalIsUsage proves the production dispatch
343+// path refuses an invalid invocation with the usage exit code and prints no
344+// report at all.
345+func TestBrokerReadySubprocessArgumentRefusalIsUsage(t *testing.T) {
346+ sandbox := newReadySubprocessSandbox(t)
347+
348+ stdout, stderr, code := sandbox.ready("1s", "--require", "remote-catalogue")
349+ require.Equal(t, 2, code, "stderr: %s", stderr)
350+ require.Empty(t, strings.TrimSpace(stdout), "an argument refusal prints no report")
351+ require.NotEmpty(t, strings.TrimSpace(stderr))
352+}
A internal/app/broker_ready_test.go +970 -0
@@ -0,0 +1,970 @@
1+package app
2+
3+// Hidden `_broker-ready` dial-only probe contract (Plan 001 P3.4).
4+//
5+// The probe selects one broker IPC endpoint, dials it, registers, subscribes,
6+// and reads complete snapshots. These tests pin the whole caller contract
7+// without a production broker process: argument parsing, the bounded JSON
8+// document, the closed exit-code table, the readiness evaluation of both
9+// `--require` modes, the terminal classification that must never retry, the
10+// reconnect on a new broker epoch, and the zero-effect guarantee (no ensure, no
11+// spawn, no logical stream, no reconcile, and no membership mutation).
12+
13+import (
14+ "bytes"
15+ "context"
16+ "encoding/json"
17+ "errors"
18+ "net"
19+ "os"
20+ "path/filepath"
21+ "strconv"
22+ "strings"
23+ "sync"
24+ "syscall"
25+ "testing"
26+ "time"
27+
28+ "github.com/stretchr/testify/require"
29+
30+ "github.com/bnema/vev/internal/adapters/brokeripc"
31+ "github.com/bnema/vev/internal/adapters/ipc"
32+ "github.com/bnema/vev/internal/domain"
33+ "github.com/bnema/vev/internal/ports"
34+ "github.com/bnema/vev/internal/protocol"
35+)
36+
37+const (
38+ readyTestEpoch = ports.BrokerEpoch(0x2a)
39+ readyTestRevision = ports.BrokerRevision(7)
40+ readyTestIdentity = ports.BrokerDaemonIdentity("ready-local-daemon")
41+ readyTestObserveMax = 60 * time.Millisecond
42+)
43+
44+// readyTestIncarnate is one valid, non-zero daemon incarnation.
45+var readyTestIncarnate = ports.BrokerDaemonIncarnation{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
46+
47+// readyTestPolicy is one valid exactly-provisioned connection policy.
48+func readyTestPolicy() ports.BrokerPolicy {
49+ return ports.BrokerPolicy{
50+ ProtocolVersion: protocol.Version,
51+ CatalogSchemaVersion: 3,
52+ EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
53+ Transport: "unix-mux",
54+ Trust: "same-user",
55+ Launch: "explicit",
56+ Isolation: "per-user",
57+ }
58+}
59+
60+// readyTestLocal builds one local daemon observation with the supplied observed
61+// state, so a test states exactly which readiness input it varies.
62+func readyTestLocal(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerDaemonObservation {
63+ local := ports.BrokerDaemonObservation{
64+ Local: true,
65+ DisplayOrigin: "local",
66+ Policy: readyTestPolicy(),
67+ Identity: readyTestIdentity,
68+ Incarnation: readyTestIncarnate,
69+ ProtocolVersion: protocol.Version,
70+ Availability: domain.RemoteAvailabilityReachable,
71+ InventoryKnown: true,
72+ LastSuccess: time.Now().UTC(),
73+ }
74+ if mutate != nil {
75+ mutate(&local)
76+ }
77+ return local
78+}
79+
80+// readyTestSnapshot builds one valid publication carrying exactly one local
81+// daemon observation.
82+func readyTestSnapshot(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerSnapshot {
83+ return ports.BrokerSnapshot{
84+ Epoch: readyTestEpoch,
85+ Revision: readyTestRevision,
86+ Daemons: []ports.BrokerDaemonObservation{readyTestLocal(mutate)},
87+ }
88+}
89+
90+// readyScriptedSubscription is one coalescing capacity-one subscription.
91+type readyScriptedSubscription struct {
92+ mu sync.Mutex
93+ changed chan struct{}
94+ closed int
95+}
96+
97+func newReadyScriptedSubscription() *readyScriptedSubscription {
98+ return &readyScriptedSubscription{changed: make(chan struct{}, 1)}
99+}
100+
101+func (s *readyScriptedSubscription) Changed() <-chan struct{} { return s.changed }
102+
103+func (s *readyScriptedSubscription) Close() {
104+ s.mu.Lock()
105+ s.closed++
106+ s.mu.Unlock()
107+}
108+
109+func (s *readyScriptedSubscription) closeCount() int {
110+ s.mu.Lock()
111+ defer s.mu.Unlock()
112+ return s.closed
113+}
114+
115+// readyScriptedService is a scripted ports.BrokerService. It serves one fixed
116+// snapshot, records every mutating or stream method it is asked for, and lets a
117+// test end its connection to drive the reconnect path.
118+type readyScriptedService struct {
119+ snapshot ports.BrokerSnapshot
120+ subErr error
121+
122+ mu sync.Mutex
123+ done chan struct{}
124+ doneOnce sync.Once
125+ subs []*readyScriptedSubscription
126+ closes int
127+ mutations []string
128+}
129+
130+func newReadyScriptedService(snapshot ports.BrokerSnapshot) *readyScriptedService {
131+ return &readyScriptedService{snapshot: snapshot, done: make(chan struct{})}
132+}
133+
134+func (s *readyScriptedService) record(name string) {
135+ s.mu.Lock()
136+ s.mutations = append(s.mutations, name)
137+ s.mu.Unlock()
138+}
139+
140+func (s *readyScriptedService) ConnectionID() ports.BrokerConnectionID {
141+ return ports.BrokerConnectionID{0x2a}
142+}
143+
144+func (s *readyScriptedService) NextStreamID() (ports.BrokerStreamID, error) { return 1, nil }
145+
146+func (s *readyScriptedService) Done() <-chan struct{} { return s.done }
147+
148+func (s *readyScriptedService) Err() error { return nil }
149+
150+func (s *readyScriptedService) Snapshot() ports.BrokerSnapshot { return s.snapshot.Clone() }
151+
152+func (s *readyScriptedService) Subscribe() (ports.BrokerSubscription, error) {
153+ if s.subErr != nil {
154+ return nil, s.subErr
155+ }
156+ sub := newReadyScriptedSubscription()
157+ s.mu.Lock()
158+ s.subs = append(s.subs, sub)
159+ s.mu.Unlock()
160+ return sub, nil
161+}
162+
163+func (s *readyScriptedService) OpenStream(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
164+ s.record("OpenStream")
165+ return nil, ports.BrokerAdmissionClosed
166+}
167+
168+func (s *readyScriptedService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {
169+ s.record("CloseStream")
170+ return nil
171+}
172+
173+func (s *readyScriptedService) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
174+ s.record("AddHost")
175+ return domain.RemoteRegistration{}, nil
176+}
177+
178+func (s *readyScriptedService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) {
179+ s.record("RemoveHost")
180+ return false, nil
181+}
182+
183+func (s *readyScriptedService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
184+ s.record("UpdateHostPolicy")
185+ return domain.RemoteRegistration{}, nil
186+}
187+
188+func (s *readyScriptedService) RequestReconcile(string) { s.record("RequestReconcile") }
189+
190+func (s *readyScriptedService) Close() error {
191+ s.mu.Lock()
192+ s.closes++
193+ s.mu.Unlock()
194+ return nil
195+}
196+
197+// finish closes Done, so the probe observes the connection going terminal.
198+func (s *readyScriptedService) finish() { s.doneOnce.Do(func() { close(s.done) }) }
199+
200+func (s *readyScriptedService) closeCount() int {
201+ s.mu.Lock()
202+ defer s.mu.Unlock()
203+ return s.closes
204+}
205+
206+// mutationNames returns the effectful methods the probe asked for, if any.
207+func (s *readyScriptedService) mutationNames() []string {
208+ s.mu.Lock()
209+ defer s.mu.Unlock()
210+ return append([]string(nil), s.mutations...)
211+}
212+
213+// subscriptionCloses returns the total Close count across this service's
214+// subscriptions.
215+func (s *readyScriptedService) subscriptionCloses() int {
216+ s.mu.Lock()
217+ subs := append([]*readyScriptedSubscription(nil), s.subs...)
218+ s.mu.Unlock()
219+ total := 0
220+ for _, sub := range subs {
221+ total += sub.closeCount()
222+ }
223+ return total
224+}
225+
226+var _ ports.BrokerService = (*readyScriptedService)(nil)
227+
228+// readyDialScript records every dial the probe performs and serves one fresh
229+// scripted service per call, so a probe is proven to reconnect rather than
230+// reuse a connection.
231+type readyDialScript struct {
232+ mu sync.Mutex
233+ dials int
234+ services []*readyScriptedService
235+ produce func() (ports.BrokerService, error)
236+}
237+
238+// scriptReadyDial replaces the probe's dial seam for one test.
239+func scriptReadyDial(t *testing.T, produce func() (ports.BrokerService, error)) *readyDialScript {
240+ t.Helper()
241+ script := &readyDialScript{produce: produce}
242+ previous := brokerReadyDial
243+ brokerReadyDial = func(context.Context, string) (ports.BrokerService, error) {
244+ script.mu.Lock()
245+ script.dials++
246+ script.mu.Unlock()
247+ service, err := script.produce()
248+ if recorded, ok := service.(*readyScriptedService); ok {
249+ script.mu.Lock()
250+ script.services = append(script.services, recorded)
251+ script.mu.Unlock()
252+ }
253+ return service, err
254+ }
255+ t.Cleanup(func() { brokerReadyDial = previous })
256+ return script
257+}
258+
259+func (s *readyDialScript) dialCount() int {
260+ s.mu.Lock()
261+ defer s.mu.Unlock()
262+ return s.dials
263+}
264+
265+func (s *readyDialScript) createdServices() []*readyScriptedService {
266+ s.mu.Lock()
267+ defer s.mu.Unlock()
268+ return append([]*readyScriptedService(nil), s.services...)
269+}
270+
271+// readyTestOptions builds one parsed probe invocation over a fresh isolated
272+// offline root, so no production path is ever inspected.
273+func readyTestOptions(t *testing.T, requirement string, timeout time.Duration) brokerReadyOptions {
274+ t.Helper()
275+ root, _, _ := isolateSandboxEnv(t)
276+ require.NoError(t, os.MkdirAll(root, 0o700))
277+ writeSandboxConfig(t, root, sandboxEmptyDocument(""))
278+ options, err := parseBrokerReadyArgs([]string{"--offline-root", root, "--require", requirement, "--timeout", timeout.String()})
279+ require.NoError(t, err)
280+ return options
281+}
282+
283+// runReadyProbe drives the real probe entry point over the scripted dial seam.
284+func runReadyProbe(t *testing.T, options brokerReadyOptions) (string, error) {
285+ t.Helper()
286+ var out bytes.Buffer
287+ err := runBrokerReady(context.Background(), options, &out)
288+ return out.String(), err
289+}
290+
291+// decodeReadyDocument asserts stdout holds exactly one bounded JSON line and
292+// returns the decoded object.
293+func decodeReadyDocument(t *testing.T, raw string) map[string]any {
294+ t.Helper()
295+ trimmed := strings.TrimSpace(raw)
296+ require.Equal(t, 1, strings.Count(trimmed, "\n")+1, "the probe writes exactly one JSON line")
297+ require.LessOrEqual(t, len(trimmed)+1, 4096, "the document stays inside its bound")
298+ var decoded map[string]any
299+ require.NoError(t, json.Unmarshal([]byte(trimmed), &decoded))
300+ require.Equal(t, "vev.broker-ready/v1", decoded["schema"])
301+ return decoded
302+}
303+
304+// TestParseBrokerReadyArgs pins the strict argument contract: unknown,
305+// duplicated, incomplete, and non-positive options are refused, a require mode
306+// outside the closed pair is refused, the observation-age bound is admitted only
307+// for the catalogue mode, and every default is explicit.
308+func TestParseBrokerReadyArgs(t *testing.T) {
309+ t.Parallel()
310+
311+ tests := []struct {
312+ name string
313+ args []string
314+ want brokerReadyOptions
315+ wantErr string
316+ }{
317+ {
318+ name: "authority defaults",
319+ args: []string{"--require", "local-authority"},
320+ want: brokerReadyOptions{require: "local-authority", timeout: 10 * time.Second, maxAge: 5 * time.Second},
321+ },
322+ {
323+ name: "catalogue with an explicit observation age",
324+ args: []string{"--require", "local-catalogue", "--max-observation-age", "2s", "--timeout", "3s", "--offline-root", "/srv/broker"},
325+ want: brokerReadyOptions{offlineRoot: "/srv/broker", require: "local-catalogue", timeout: 3 * time.Second, maxAge: 2 * time.Second},
326+ },
327+ {name: "missing require", args: nil, wantErr: "`--require` must be local-authority or local-catalogue"},
328+ {name: "unknown require", args: []string{"--require", "remote-catalogue"}, wantErr: "`--require` must be local-authority or local-catalogue"},
329+ {name: "missing value", args: []string{"--require"}, wantErr: "requires a value"},
330+ {name: "duplicate option", args: []string{"--require", "local-authority", "--require", "local-authority"}, wantErr: "duplicate"},
331+ {name: "unknown option", args: []string{"--require", "local-authority", "--ensure", "1"}, wantErr: "unknown `--ensure` option"},
332+ {name: "timeout is not a duration", args: []string{"--require", "local-authority", "--timeout", "soon"}, wantErr: "`--timeout` must be positive"},
333+ {name: "timeout is zero", args: []string{"--require", "local-authority", "--timeout", "0s"}, wantErr: "`--timeout` must be positive"},
334+ {name: "timeout is negative", args: []string{"--require", "local-authority", "--timeout", "-1s"}, wantErr: "`--timeout` must be positive"},
335+ {name: "observation age is not a duration", args: []string{"--require", "local-catalogue", "--max-observation-age", "later"}, wantErr: "`--max-observation-age` must be positive"},
336+ {name: "observation age is zero", args: []string{"--require", "local-catalogue", "--max-observation-age", "0s"}, wantErr: "`--max-observation-age` must be positive"},
337+ {
338+ name: "observation age is authority-only",
339+ args: []string{"--require", "local-authority", "--max-observation-age", "2s"},
340+ wantErr: "`--max-observation-age` is only valid for local-catalogue",
341+ },
342+ {name: "relative offline root", args: []string{"--require", "local-authority", "--offline-root", "relative/root"}, wantErr: "`--offline-root` must be an absolute clean path"},
343+ {name: "unclean offline root", args: []string{"--require", "local-authority", "--offline-root", "/srv/../srv/broker"}, wantErr: "`--offline-root` must be an absolute clean path"},
344+ }
345+ for _, tt := range tests {
346+ t.Run(tt.name, func(t *testing.T) {
347+ t.Parallel()
348+ got, err := parseBrokerReadyArgs(tt.args)
349+ if tt.wantErr != "" {
350+ require.Error(t, err)
351+ require.ErrorContains(t, err, tt.wantErr)
352+ require.Equal(t, 2, ExitCode(err), "an argument refusal is a usage exit")
353+ return
354+ }
355+ require.NoError(t, err)
356+ require.Equal(t, tt.want, got)
357+ })
358+ }
359+}
360+
361+// TestBrokerReadyDocumentIsOneBoundedJSONLine pins the machine contract: exactly
362+// one JSON line, decimal-string epoch/revision IDs, the pending list, and the
363+// observed local projection, including its hex incarnation and RFC3339
364+// observation time.
365+func TestBrokerReadyDocumentIsOneBoundedJSONLine(t *testing.T) {
366+ t.Parallel()
367+
368+ local := readyTestLocal(func(local *ports.BrokerDaemonObservation) {
369+ local.LastSuccess = time.Date(2026, 9, 21, 12, 0, 0, 123456789, time.UTC)
370+ })
371+ options := brokerReadyOptions{require: "local-catalogue", timeout: time.Second, maxAge: time.Second}
372+
373+ tests := []struct {
374+ name string
375+ result brokerReadyResult
376+ }{
377+ {name: "absent local", result: readyResult(options, "pending", "local-not-ready", nil)},
378+ {name: "observed local", result: readyResult(options, "pending", "local-not-ready", &local)},
379+ {name: "ready snapshot", result: evaluateReady(options, readyTestSnapshot(func(observed *ports.BrokerDaemonObservation) { *observed = local }), local)},
380+ }
381+ for _, tt := range tests {
382+ t.Run(tt.name, func(t *testing.T) {
383+ t.Parallel()
384+ var out bytes.Buffer
385+ // emitReady reports the exit code for a non-ready status; the document
386+ // is written either way, which is the property under test here.
387+ _ = emitReady(&out, tt.result)
388+
389+ raw := out.String()
390+ require.True(t, strings.HasSuffix(raw, "\n"), "the document ends with exactly one newline")
391+ decoded := decodeReadyDocument(t, raw)
392+ require.Equal(t, tt.result.Status, decoded["status"])
393+ require.Equal(t, tt.result.Require, decoded["require"])
394+ require.Equal(t, tt.result.Reason, decoded["reason"])
395+ require.Equal(t, toAnySlice(tt.result.Pending), decoded["pending"])
396+ require.Equal(t, tt.result.Epoch, decoded["epoch"], "epoch travels as a decimal string")
397+ require.Equal(t, tt.result.Revision, decoded["revision"], "revision travels as a decimal string")
398+
399+ if tt.result.Local == nil {
400+ require.Nil(t, decoded["local"])
401+ return
402+ }
403+ observed, ok := decoded["local"].(map[string]any)
404+ require.True(t, ok, "an observed local is a JSON object")
405+ require.Equal(t, "ready-local-daemon", observed["identity"])
406+ require.Equal(t, "0102030405060708090a0b0c0d0e0f10", observed["incarnation"])
407+ require.Equal(t, strconv.FormatUint(uint64(protocol.Version), 10), observed["version"])
408+ require.Equal(t, "reachable", observed["availability"])
409+ require.Equal(t, true, observed["inventory_known"])
410+ require.Equal(t, "2026-09-21T12:00:00.123456789Z", observed["last_success"])
411+ })
412+ }
413+}
414+
415+// TestBrokerReadyExitCodeTable pins the closed status-to-exit-code mapping: 0
416+// ready, 3 timeout (including the default pending case), 4 terminal, and 5
417+// cancellation. The document is written before the exit code is returned.
418+func TestBrokerReadyExitCodeTable(t *testing.T) {
419+ t.Parallel()
420+
421+ tests := []struct {
422+ name string
423+ status string
424+ wantCode int
425+ wantErrMsg string
426+ }{
427+ {name: "ready", status: "ready", wantCode: 0},
428+ {name: "timeout", status: "timeout", wantCode: 3, wantErrMsg: "broker readiness timeout"},
429+ {name: "terminal", status: "terminal", wantCode: 4, wantErrMsg: "broker readiness terminal failure"},
430+ {name: "canceled", status: "canceled", wantCode: 5, wantErrMsg: "context canceled"},
431+ {name: "pending", status: "pending", wantCode: 3, wantErrMsg: "broker readiness timeout"},
432+ }
433+ for _, tt := range tests {
434+ t.Run(tt.name, func(t *testing.T) {
435+ t.Parallel()
436+ var out bytes.Buffer
437+ result := brokerReadyResult{Schema: "vev.broker-ready/v1", Status: tt.status, Require: "local-authority", Pending: []string{}}
438+ err := emitReady(&out, result)
439+ require.Equal(t, tt.wantCode, ExitCode(err))
440+ if tt.wantErrMsg == "" {
441+ require.NoError(t, err)
442+ } else {
443+ require.ErrorContains(t, err, tt.wantErrMsg)
444+ }
445+ require.Equal(t, 1, strings.Count(out.String(), "\n"), "the report is written even when the exit code is non-zero")
446+ })
447+ }
448+}
449+
450+// TestBrokerReadyObservationTable pins the readiness evaluation itself:
451+// `local-authority` is satisfied by any snapshot that carries a valid local
452+// entry, `local-catalogue` requires a reachable, known, fresh, version-exact
453+// observation, an empty known inventory is a success, and a structurally
454+// invalid or doubled local publication is terminal rather than pending.
455+func TestBrokerReadyObservationTable(t *testing.T) {
456+ tests := []struct {
457+ name string
458+ require string
459+ snapshot ports.BrokerSnapshot
460+ subErr error
461+ wantNil bool
462+ wantStatus string
463+ wantReason string
464+ wantEpoch string
465+ wantRevision string
466+ wantPending []string
467+ }{
468+ {
469+ name: "unknown availability satisfies the authority requirement",
470+ require: "local-authority",
471+ snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.Availability = domain.RemoteAvailabilityUnknown }),
472+ wantStatus: "ready",
473+ wantReason: "authority",
474+ wantEpoch: "42",
475+ wantRevision: "7",
476+ wantPending: []string{},
477+ },
478+ {
479+ name: "reachable known empty inventory satisfies the catalogue requirement",
480+ require: "local-catalogue",
481+ snapshot: readyTestSnapshot(nil),
482+ wantStatus: "ready",
483+ wantReason: "catalogue",
484+ wantEpoch: "42",
485+ wantRevision: "7",
486+ wantPending: []string{},
487+ },
488+ {
489+ name: "unreachable local stays pending",
490+ require: "local-catalogue",
491+ snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.Availability = domain.RemoteAvailabilityUnreachable }),
492+ wantNil: true,
493+ wantStatus: "pending",
494+ wantPending: []string{"local-catalogue"},
495+ },
496+ {
497+ name: "unknown inventory stays pending",
498+ require: "local-catalogue",
499+ snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.InventoryKnown = false }),
500+ wantNil: true,
501+ wantStatus: "pending",
502+ wantPending: []string{"local-catalogue"},
503+ },
504+ {
505+ name: "stale observation stays pending",
506+ require: "local-catalogue",
507+ snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.LastSuccess = time.Now().Add(-time.Hour) }),
508+ wantNil: true,
509+ wantStatus: "pending",
510+ wantPending: []string{"local-catalogue"},
511+ },
512+ {
513+ name: "missing observation time stays pending",
514+ require: "local-catalogue",
515+ snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.LastSuccess = time.Time{} }),
516+ wantNil: true,
517+ wantStatus: "pending",
518+ wantPending: []string{"local-catalogue"},
519+ },
520+ {
521+ name: "incompatible protocol version stays pending",
522+ require: "local-catalogue",
523+ snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.ProtocolVersion = protocol.Version + 1 }),
524+ wantNil: true,
525+ wantStatus: "pending",
526+ wantPending: []string{"local-catalogue"},
527+ },
528+ {
529+ name: "unobserved local stays pending",
530+ require: "local-catalogue",
531+ snapshot: readyTestSnapshot(func(local *ports.BrokerDaemonObservation) {
532+ *local = ports.BrokerDaemonObservation{Local: true, DisplayOrigin: "local", Policy: readyTestPolicy(), Availability: domain.RemoteAvailabilityUnknown}
533+ }),
534+ wantNil: true,
535+ wantStatus: "pending",
536+ wantPending: []string{"local-catalogue"},
537+ },
538+ {
539+ name: "snapshot without an epoch is no observation at all",
540+ require: "local-authority",
541+ snapshot: ports.BrokerSnapshot{},
542+ wantNil: true,
543+ wantStatus: "pending",
544+ wantPending: []string{"local-authority"},
545+ },
546+ {
547+ name: "snapshot without a revision is terminal",
548+ require: "local-authority",
549+ snapshot: ports.BrokerSnapshot{Epoch: readyTestEpoch, Revision: 0, Daemons: []ports.BrokerDaemonObservation{readyTestLocal(nil)}},
550+ wantStatus: "terminal",
551+ wantReason: "protocol",
552+ wantEpoch: "0",
553+ wantRevision: "0",
554+ wantPending: []string{"local-authority"},
555+ },
556+ {
557+ name: "two local daemons are terminal",
558+ require: "local-authority",
559+ snapshot: ports.BrokerSnapshot{
560+ Epoch: readyTestEpoch, Revision: readyTestRevision,
561+ Daemons: []ports.BrokerDaemonObservation{readyTestLocal(nil), readyTestLocal(nil)},
562+ },
563+ wantStatus: "terminal",
564+ wantReason: "protocol",
565+ wantEpoch: "0",
566+ wantRevision: "0",
567+ wantPending: []string{"local-authority"},
568+ },
569+ {
570+ name: "subscription refusal is terminal",
571+ require: "local-authority",
572+ snapshot: readyTestSnapshot(nil),
573+ subErr: errors.Join(brokeripc.ErrProtocol, errors.New("broker refused the subscription")),
574+ wantStatus: "terminal",
575+ wantReason: "protocol",
576+ wantEpoch: "0",
577+ wantRevision: "0",
578+ wantPending: []string{"local-authority"},
579+ },
580+ }
581+ for _, tt := range tests {
582+ t.Run(tt.name, func(t *testing.T) {
583+ service := newReadyScriptedService(tt.snapshot)
584+ service.subErr = tt.subErr
585+ ctx, cancel := context.WithTimeout(context.Background(), readyTestObserveMax)
586+ defer cancel()
587+
588+ result, reconnect := observeBrokerReady(ctx, brokerReadyOptions{require: tt.require, timeout: time.Second, maxAge: time.Second}, service)
589+
590+ require.False(t, reconnect, "a terminal or satisfied observation never reconnects")
591+ require.Empty(t, service.mutationNames(), "readiness must never open a stream, reconcile, or mutate membership")
592+ if tt.subErr != nil {
593+ require.Zero(t, service.subscriptionCloses(), "a refused subscription is never closed as if it existed")
594+ } else {
595+ require.Equal(t, 1, service.subscriptionCloses(), "the probe closes the subscription it opened")
596+ }
597+ if tt.wantNil {
598+ require.Nil(t, result, "an unsatisfied observation publishes nothing")
599+ return
600+ }
601+ require.NotNil(t, result)
602+ require.Equal(t, tt.wantStatus, result.Status)
603+ require.Equal(t, tt.wantReason, result.Reason)
604+ require.Equal(t, tt.wantEpoch, result.Epoch)
605+ require.Equal(t, tt.wantRevision, result.Revision)
606+ require.Equal(t, tt.wantPending, result.Pending)
607+ require.Equal(t, "vev.broker-ready/v1", result.Schema)
608+ })
609+ }
610+}
611+
612+// TestBrokerReadyRunOutcomeTable drives the real probe entry point for every
613+// exit class: an on-demand broker that is merely absent or refused is retried
614+// into the deadline (3), a security, configuration, or protocol refusal is
615+// terminal (4), a satisfied requirement is 0, and every connection the probe
616+// opened is closed exactly once with no effectful call.
617+func TestBrokerReadyRunOutcomeTable(t *testing.T) {
618+ tests := []struct {
619+ name string
620+ require string
621+ produce func() (ports.BrokerService, error)
622+ wantCode int
623+ wantStatus string
624+ wantReason string
625+ wantPending []string
626+ }{
627+ {
628+ name: "authority satisfied",
629+ require: "local-authority",
630+ produce: func() (ports.BrokerService, error) { return newReadyScriptedService(readyTestSnapshot(nil)), nil },
631+ wantCode: 0,
632+ wantStatus: "ready",
633+ wantReason: "authority",
634+ wantPending: []string{},
635+ },
636+ {
637+ name: "catalogue satisfied by a known empty inventory",
638+ require: "local-catalogue",
639+ produce: func() (ports.BrokerService, error) { return newReadyScriptedService(readyTestSnapshot(nil)), nil },
640+ wantCode: 0,
641+ wantStatus: "ready",
642+ wantReason: "catalogue",
643+ wantPending: []string{},
644+ },
645+ {
646+ name: "catalogue with an incompatible version times out",
647+ require: "local-catalogue",
648+ produce: func() (ports.BrokerService, error) {
649+ return newReadyScriptedService(readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.ProtocolVersion = protocol.Version + 1 })), nil
650+ },
651+ wantCode: 3,
652+ wantStatus: "timeout",
653+ wantReason: "deadline",
654+ wantPending: []string{"local-catalogue"},
655+ },
656+ {
657+ name: "stale catalogue observation times out",
658+ require: "local-catalogue",
659+ produce: func() (ports.BrokerService, error) {
660+ return newReadyScriptedService(readyTestSnapshot(func(local *ports.BrokerDaemonObservation) { local.LastSuccess = time.Now().Add(-time.Hour) })), nil
661+ },
662+ wantCode: 3,
663+ wantStatus: "timeout",
664+ wantReason: "deadline",
665+ wantPending: []string{"local-catalogue"},
666+ },
667+ {
668+ name: "no snapshot times out",
669+ require: "local-authority",
670+ produce: func() (ports.BrokerService, error) { return newReadyScriptedService(ports.BrokerSnapshot{}), nil },
671+ wantCode: 3,
672+ wantStatus: "timeout",
673+ wantReason: "deadline",
674+ wantPending: []string{"local-authority"},
675+ },
676+ {
677+ name: "absent endpoint is retried then times out",
678+ require: "local-authority",
679+ produce: func() (ports.BrokerService, error) {
680+ return nil, &net.OpError{Op: "dial", Net: "unix", Err: syscall.ENOENT}
681+ },
682+ wantCode: 3,
683+ wantStatus: "timeout",
684+ wantReason: "deadline",
685+ wantPending: []string{"local-authority"},
686+ },
687+ {
688+ name: "refused endpoint is retried then times out",
689+ require: "local-authority",
690+ produce: func() (ports.BrokerService, error) {
691+ return nil, &net.OpError{Op: "dial", Net: "unix", Err: syscall.ECONNREFUSED}
692+ },
693+ wantCode: 3,
694+ wantStatus: "timeout",
695+ wantReason: "deadline",
696+ wantPending: []string{"local-authority"},
697+ },
698+ {
699+ name: "invalid snapshot is terminal",
700+ require: "local-authority",
701+ produce: func() (ports.BrokerService, error) {
702+ return newReadyScriptedService(ports.BrokerSnapshot{Epoch: readyTestEpoch}), nil
703+ },
704+ wantCode: 4,
705+ wantStatus: "terminal",
706+ wantReason: "protocol",
707+ wantPending: []string{"local-authority"},
708+ },
709+ {
710+ name: "rejected peer is terminal",
711+ require: "local-authority",
712+ produce: func() (ports.BrokerService, error) { return nil, ipc.ErrMuxPeerRejected },
713+ wantCode: 4,
714+ wantStatus: "terminal",
715+ wantReason: "security",
716+ wantPending: []string{"local-authority"},
717+ },
718+ {
719+ name: "permission refusal is terminal",
720+ require: "local-authority",
721+ produce: func() (ports.BrokerService, error) { return nil, os.ErrPermission },
722+ wantCode: 4,
723+ wantStatus: "terminal",
724+ wantReason: "security",
725+ wantPending: []string{"local-authority"},
726+ },
727+ {
728+ name: "invalid configuration is terminal",
729+ require: "local-authority",
730+ produce: func() (ports.BrokerService, error) { return nil, brokeripc.ErrConfig },
731+ wantCode: 4,
732+ wantStatus: "terminal",
733+ wantReason: "config",
734+ wantPending: []string{"local-authority"},
735+ },
736+ {
737+ name: "broker protocol violation is terminal",
738+ require: "local-authority",
739+ produce: func() (ports.BrokerService, error) {
740+ return nil, errors.Join(brokeripc.ErrMalformedFrame, errors.New("truncated"))
741+ },
742+ wantCode: 4,
743+ wantStatus: "terminal",
744+ wantReason: "protocol",
745+ wantPending: []string{"local-authority"},
746+ },
747+ }
748+ for _, tt := range tests {
749+ t.Run(tt.name, func(t *testing.T) {
750+ options := readyTestOptions(t, tt.require, 80*time.Millisecond)
751+ script := scriptReadyDial(t, tt.produce)
752+
753+ out, err := runReadyProbe(t, options)
754+ require.Equal(t, tt.wantCode, ExitCode(err), "probe outcome %q", out)
755+
756+ decoded := decodeReadyDocument(t, out)
757+ require.Equal(t, tt.wantStatus, decoded["status"])
758+ require.Equal(t, tt.wantReason, decoded["reason"])
759+ require.Equal(t, tt.require, decoded["require"])
760+ require.Equal(t, toAnySlice(tt.wantPending), decoded["pending"])
761+
762+ services := script.createdServices()
763+ for _, service := range services {
764+ require.Equal(t, 1, service.closeCount(), "every dialed connection is closed exactly once")
765+ require.Empty(t, service.mutationNames(), "readiness opens no stream, reconciles nothing, and mutates no membership")
766+ require.Equal(t, 1, service.subscriptionCloses(), "the probe closes the subscription it opened")
767+ }
768+ if tt.wantCode == 0 {
769+ require.Equal(t, 1, script.dialCount(), "a satisfied probe dials exactly once")
770+ }
771+ })
772+ }
773+}
774+
775+// TestBrokerReadyNeverEnsuresSpawnsOrMutates pins the zero-effect guarantee at
776+// the probe level: the dial seam is the only I/O seam it uses, every connection
777+// it opens is closed, every subscription is closed, and no logical stream,
778+// reconcile, or membership mutation is ever attempted.
779+func TestBrokerReadyNeverEnsuresSpawnsOrMutates(t *testing.T) {
780+ options := readyTestOptions(t, "local-authority", 80*time.Millisecond)
781+ script := scriptReadyDial(t, func() (ports.BrokerService, error) {
782+ return newReadyScriptedService(readyTestSnapshot(nil)), nil
783+ })
784+
785+ out, err := runReadyProbe(t, options)
786+ require.NoError(t, err, "outcome %q", out)
787+ require.Equal(t, 1, script.dialCount(), "a satisfied probe dials exactly once")
788+ require.Len(t, script.createdServices(), 1)
789+
790+ service := script.createdServices()[0]
791+ require.Empty(t, service.mutationNames(), "readiness opens no stream, reconciles nothing, and mutates no membership")
792+ require.Equal(t, 1, service.closeCount())
793+ require.Equal(t, 1, service.subscriptionCloses())
794+
795+ decoded := decodeReadyDocument(t, out)
796+ require.Equal(t, "ready", decoded["status"])
797+ observed, ok := decoded["local"].(map[string]any)
798+ require.True(t, ok)
799+ require.Equal(t, "reachable", observed["availability"])
800+ require.Equal(t, true, observed["inventory_known"])
801+}
802+
803+// TestBrokerReadyReconnectUsesTheNewEpoch pins that a broker connection that
804+// ends before readiness is replaced by a fresh dial, and that the report carries
805+// the new broker epoch: a retired epoch is never reused as readiness authority.
806+func TestBrokerReadyReconnectUsesTheNewEpoch(t *testing.T) {
807+ freshSnapshot := ports.BrokerSnapshot{Epoch: ports.BrokerEpoch(2), Revision: 3, Daemons: []ports.BrokerDaemonObservation{readyTestLocal(nil)}}
808+
809+ options := readyTestOptions(t, "local-authority", 2*time.Second)
810+ var (
811+ mu sync.Mutex
812+ first *readyScriptedService
813+ attempts int
814+ )
815+ scriptReadyDial(t, func() (ports.BrokerService, error) {
816+ mu.Lock()
817+ defer mu.Unlock()
818+ attempts++
819+ if attempts == 1 {
820+ // The first connection commits no snapshot before it retires: the
821+ // probe must reconnect rather than report the retired connection.
822+ first = newReadyScriptedService(ports.BrokerSnapshot{})
823+ first.finish()
824+ return first, nil
825+ }
826+ return newReadyScriptedService(freshSnapshot), nil
827+ })
828+
829+ out, err := runReadyProbe(t, options)
830+ require.NoError(t, err, "outcome %q", out)
831+
832+ decoded := decodeReadyDocument(t, out)
833+ require.Equal(t, "ready", decoded["status"])
834+ require.Equal(t, "2", decoded["epoch"], "the report carries the epoch of the connection that answered")
835+ require.NotEqual(t, "1", decoded["epoch"], "a retired epoch is never reused")
836+
837+ mu.Lock()
838+ defer mu.Unlock()
839+ require.Equal(t, 2, attempts, "a retired connection is replaced by exactly one fresh dial")
840+ require.NotNil(t, first)
841+ require.Equal(t, 1, first.closeCount(), "the retired connection is closed exactly once")
842+}
843+
844+// TestBrokerReadyCancellationIsExitFiveAndClosesEverything pins the
845+// cancellation contract: the caller sees exit 5, the document says canceled,
846+// and the connection and subscription the probe opened are closed rather than
847+// leaked.
848+func TestBrokerReadyCancellationIsExitFiveAndClosesEverything(t *testing.T) {
849+ options := readyTestOptions(t, "local-authority", 5*time.Second)
850+ script := scriptReadyDial(t, func() (ports.BrokerService, error) {
851+ return newReadyScriptedService(ports.BrokerSnapshot{}), nil
852+ })
853+
854+ ctx, cancel := context.WithCancel(context.Background())
855+ var out bytes.Buffer
856+ results := make(chan error, 1)
857+ go func() { results <- runBrokerReady(ctx, options, &out) }()
858+
859+ require.Eventually(t, func() bool { return script.dialCount() == 1 }, time.Second, 5*time.Millisecond, "the probe dials before it is cancelled")
860+ cancel()
861+
862+ select {
863+ case err := <-results:
864+ require.Equal(t, 5, ExitCode(err))
865+ case <-time.After(2 * time.Second):
866+ t.Fatal("a cancelled probe must return promptly")
867+ }
868+
869+ decoded := decodeReadyDocument(t, out.String())
870+ require.Equal(t, "canceled", decoded["status"])
871+ require.Equal(t, "canceled", decoded["reason"])
872+
873+ services := script.createdServices()
874+ require.Len(t, services, 1)
875+ require.Equal(t, 1, services[0].closeCount(), "cancellation closes the connection")
876+ require.Equal(t, 1, services[0].subscriptionCloses(), "cancellation closes the subscription")
877+ require.Empty(t, services[0].mutationNames())
878+}
879+
880+// TestBrokerReadyEndpointSecurityClassificationTable pins the endpoint check the
881+// probe performs before any dial: absence is retryable, and a foreign path or an
882+// uninspectable path is a terminal security failure.
883+func TestBrokerReadyEndpointSecurityClassificationTable(t *testing.T) {
884+ t.Parallel()
885+
886+ dir := t.TempDir()
887+ regular := filepath.Join(dir, "not-a-socket")
888+ require.NoError(t, os.WriteFile(regular, []byte("x"), 0o600))
889+
890+ socketDir := t.TempDir()
891+ socketPath := filepath.Join(socketDir, "broker.sock")
892+ listener, err := net.Listen("unix", socketPath)
893+ require.NoError(t, err)
894+ t.Cleanup(func() { _ = listener.Close() })
895+
896+ unreadableDir := filepath.Join(t.TempDir(), "unreadable")
897+ require.NoError(t, os.Mkdir(unreadableDir, 0o700))
898+ unreadable := filepath.Join(unreadableDir, "broker.sock")
899+ require.NoError(t, os.Chmod(unreadableDir, 0o000))
900+ t.Cleanup(func() { _ = os.Chmod(unreadableDir, 0o700) })
901+
902+ tests := []struct {
903+ name string
904+ path string
905+ wantTerminal bool
906+ wantReason string
907+ }{
908+ {name: "absent endpoint is retryable", path: filepath.Join(dir, "absent.sock")},
909+ {name: "bound socket is dialable", path: socketPath},
910+ {name: "regular file is terminal", path: regular, wantTerminal: true, wantReason: "security"},
911+ {name: "directory is terminal", path: socketDir, wantTerminal: true, wantReason: "security"},
912+ {name: "uninspectable path is terminal", path: unreadable, wantTerminal: true, wantReason: "security"},
913+ }
914+ for _, tt := range tests {
915+ t.Run(tt.name, func(t *testing.T) {
916+ t.Parallel()
917+ reason, terminal := brokerReadyEndpointSecurity(tt.path)
918+ require.Equal(t, tt.wantTerminal, terminal)
919+ require.Equal(t, tt.wantReason, reason)
920+ })
921+ }
922+}
923+
924+// TestBrokerReadyDialClassificationTable pins the dial-failure taxonomy: only a
925+// refusal that proves the peer, the path, the credentials, the configuration, or
926+// the broker conversation itself is not the expected broker is terminal.
927+func TestBrokerReadyDialClassificationTable(t *testing.T) {
928+ t.Parallel()
929+
930+ tests := []struct {
931+ name string
932+ err error
933+ wantTerminal bool
934+ wantReason string
935+ }{
936+ {name: "nil is not a failure", err: nil},
937+ {name: "absent endpoint is retryable", err: &net.OpError{Op: "dial", Net: "unix", Err: syscall.ENOENT}},
938+ {name: "refused endpoint is retryable", err: &net.OpError{Op: "dial", Net: "unix", Err: syscall.ECONNREFUSED}},
939+ {name: "cancellation is retryable", err: context.Canceled},
940+ {name: "deadline is retryable", err: context.DeadlineExceeded},
941+ {name: "unclassified failure is retryable", err: errors.New("io error")},
942+ {name: "rejected peer is terminal", err: ipc.ErrMuxPeerRejected, wantTerminal: true, wantReason: "security"},
943+ {name: "foreign path is terminal", err: ipc.ErrMuxForeignPath, wantTerminal: true, wantReason: "security"},
944+ {name: "invalid mux path is terminal", err: ipc.ErrMuxPath, wantTerminal: true, wantReason: "security"},
945+ {name: "unsupported peer check is terminal", err: ipc.ErrMuxUnsupported, wantTerminal: true, wantReason: "security"},
946+ {name: "permission failure is terminal", err: &net.OpError{Op: "dial", Net: "unix", Err: syscall.EACCES}, wantTerminal: true, wantReason: "security"},
947+ {name: "invalid configuration is terminal", err: brokeripc.ErrConfig, wantTerminal: true, wantReason: "config"},
948+ {name: "broker protocol violation is terminal", err: brokeripc.ErrProtocol, wantTerminal: true, wantReason: "protocol"},
949+ {name: "malformed frame is terminal", err: brokeripc.ErrMalformedFrame, wantTerminal: true, wantReason: "protocol"},
950+ {name: "oversize non-broker frame is terminal", err: ipc.ErrFrameTooLarge, wantTerminal: true, wantReason: "protocol"},
951+ {name: "zero-length non-broker frame is terminal", err: ipc.ErrZeroLengthFrame, wantTerminal: true, wantReason: "protocol"},
952+ }
953+ for _, tt := range tests {
954+ t.Run(tt.name, func(t *testing.T) {
955+ t.Parallel()
956+ reason, terminal := terminalReadyReason(tt.err)
957+ require.Equal(t, tt.wantTerminal, terminal, "cause %v", tt.err)
958+ require.Equal(t, tt.wantReason, reason)
959+ })
960+ }
961+}
962+
963+// toAnySlice renders a decoded JSON string array comparison input.
964+func toAnySlice(values []string) []any {
965+ out := make([]any, 0, len(values))
966+ for _, value := range values {
967+ out = append(out, value)
968+ }
969+ return out
970+}
M internal/app/broker_remote_probe.go +98 -20
@@ -14,29 +14,39 @@ import (
1414 "github.com/bnema/vev/internal/protocol/catalogue"
1515 )
1616
17+// Remote daemon observation composition (Plan 001 P7.4b).
18+//
1719 // brokerRemoteProbe observes one configured daemon over the broker-owned
1820 // multiplexed carriage. It opens an observation stream, never an attachment,
1921 // and asks the daemon for its local catalogue through the existing typed
2022 // command protocol. Authenticated daemon identity and process incarnation come
2123 // from the physical handshake rather than from the configured hostname.
24+//
25+// Remote and local observation share one semantic implementation below:
26+// observeDaemonCatalogue is the single "observe an already authenticated
27+// daemon" path, so the two producers cannot drift apart and neither becomes a
28+// second owner of a transport. The broker-owned local probe composes the same
29+// daemonmux carriage and the same pooled connector the broker already owns
30+// rather than dialing or authenticating a daemon itself.
2231 type brokerRemoteProbe struct {
2332 epoch ports.BrokerEpoch
24- resolver ports.BrokerEndpointResolver
33+ routes ports.BrokerRouteAuthority
34+ binder ports.BrokerIdentityBinder
2535 connector ports.BrokerEndpointConnector
26- policy func(string) (ports.BrokerPolicy, bool)
36+ hosts ports.BrokerHostAuthorityReader
2737 }
2838
2939 var _ ports.BrokerHostProbe = (*brokerRemoteProbe)(nil)
3040
3141 func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) {
32- if p == nil || p.epoch == 0 || p.resolver == nil || p.connector == nil {
42+ if p == nil || p.epoch == 0 || p.routes == nil || p.binder == nil || p.connector == nil {
3343 return ports.BrokerDaemonObservation{}, errors.New("vev: incomplete broker remote probe")
3444 }
35- request, err := p.request(registration)
45+ request, err := p.request(ctx, registration)
3646 if err != nil {
3747 return ports.BrokerDaemonObservation{}, err
3848 }
39- endpoint, err := p.resolver.Resolve(ctx, request)
49+ endpoint, err := p.routes.ResolveDialTarget(ctx, request)
4050 if err != nil {
4151 return ports.BrokerDaemonObservation{}, err
4252 }
@@ -45,35 +55,59 @@ func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.Remot
4555 return ports.BrokerDaemonObservation{}, err
4656 }
4757 defer physical.Close()
48- if physical.Identity() != endpoint.Identity || physical.Policy() != endpoint.Policy {
58+ if (endpoint.ExpectedIdentity.Bound && physical.Identity() != endpoint.ExpectedIdentity.Identity) || physical.Policy() != endpoint.Policy {
4959 return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe authenticated binding mismatch")
5060 }
61+ identity, err := p.binder.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: endpoint.Fence, Policy: endpoint.Policy, Identity: physical.Identity()})
62+ if err != nil || identity != physical.Identity() {
63+ return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe could not commit authenticated binding")
64+ }
65+ return observeDaemonCatalogue(ctx, physical, request)
66+}
67+
68+// observeDaemonCatalogue observes one already authenticated daemon over its
69+// physical connection by opening exactly one observation logical stream and
70+// asking the daemon for its own catalogue through the existing typed command
71+// protocol. It sends no Hello, claims no geometry, requests no admission, and
72+// creates no session or process: the only traffic on the stream is the
73+// observation open and the one correlated remote-catalog command. The observed
74+// identity and incarnation are the physical handshake's authenticated values,
75+// never a configured hostname.
76+//
77+// A refused open, an unmatched or invalid result, a failed outcome, a malformed
78+// catalogue, and a catalogue outside the exact current schema all fail closed:
79+// the caller receives an error and no observation it could publish, so a probe
80+// never invents inventory it did not observe.
81+func observeDaemonCatalogue(ctx context.Context, physical ports.BrokerPhysicalConnection, request ports.BrokerOpenStreamRequest) (ports.BrokerDaemonObservation, error) {
82+ if physical == nil || request.Purpose != ports.BrokerStreamObservation {
83+ return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation request")
84+ }
5185 logical, err := physical.OpenStream(ctx, request)
5286 if err != nil {
5387 return ports.BrokerDaemonObservation{}, err
5488 }
55- defer logical.Close()
89+ if logical == nil {
90+ return ports.BrokerDaemonObservation{}, errors.New("vev: daemon observation opened no logical stream")
91+ }
92+ defer func() { _ = logical.Close() }()
5693 commandID, err := randomNonzeroUint64()
5794 if err != nil {
5895 return ports.BrokerDaemonObservation{}, err
5996 }
60- if err := logical.SendClient(protocol.CommandRequest{RequestID: commandID, Version: protocol.Version, Slug: "remote-catalog", JSON: true}); err != nil {
61- return ports.BrokerDaemonObservation{}, err
62- }
63- reply, err := logical.ReceiveServer()
97+ reply, err := exchangeObservationCommand(ctx, logical, protocol.CommandRequest{RequestID: commandID, Version: protocol.Version, Slug: "remote-catalog", JSON: true})
6498 if err != nil {
6599 return ports.BrokerDaemonObservation{}, err
66100 }
67101 result, ok := reply.(protocol.CommandResult)
68102 if !ok || result.RequestID != commandID || !result.Valid() {
69- return ports.BrokerDaemonObservation{}, errors.New("vev: invalid remote observation result")
103+ return ports.BrokerDaemonObservation{}, errors.New("vev: invalid daemon observation result")
70104 }
71105 if result.Outcome != protocol.CommandSucceeded {
72- return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: remote observation failed: %s", result.Text)
106+ return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: daemon observation failed: %s", result.Text)
73107 }
74108 var catalog catalogue.RemoteCatalog
75109 if err := json.Unmarshal([]byte(result.Output), &catalog); err != nil {
76- return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: decode remote observation: %w", err)
110+ return ports.BrokerDaemonObservation{}, fmt.Errorf("vev: decode daemon observation: %w", err)
77111 }
78112 if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
79113 return ports.BrokerDaemonObservation{}, err
@@ -82,21 +116,62 @@ func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.Remot
82116 Identity: physical.Identity(),
83117 Incarnation: physical.Incarnation(),
84118 ProtocolVersion: catalog.ProtocolVersion,
85- Capabilities: 0,
86119 Availability: domain.RemoteAvailabilityReachable,
87120 InventoryKnown: true,
88121 Sessions: catalog.Sessions,
89122 }, nil
90123 }
91124
92-func (p *brokerRemoteProbe) request(registration domain.RemoteRegistration) (ports.BrokerOpenStreamRequest, error) {
93- if p.policy == nil {
125+// exchangeObservationCommand sends one typed command and waits for one reply
126+// under the caller's context. A cancelled or expired context closes exactly
127+// this logical stream, which unblocks the pending read, and the worker is
128+// joined before returning, so an observation always returns promptly once its
129+// attempt is retired instead of parking the daemon's reader (or a probe
130+// attempt) on a daemon that answers nothing.
131+func exchangeObservationCommand(ctx context.Context, logical ports.BrokerLogicalConnection, command protocol.CommandRequest) (protocol.ServerMessage, error) {
132+ if ctx == nil {
133+ ctx = context.Background()
134+ }
135+ type outcome struct {
136+ message protocol.ServerMessage
137+ err error
138+ }
139+ done := make(chan outcome, 1)
140+ go func() {
141+ if err := logical.SendClient(command); err != nil {
142+ done <- outcome{err: err}
143+ return
144+ }
145+ message, err := logical.ReceiveServer()
146+ done <- outcome{message: message, err: err}
147+ }()
148+ select {
149+ case result := <-done:
150+ if err := ctx.Err(); err != nil {
151+ return nil, err
152+ }
153+ return result.message, result.err
154+ case <-ctx.Done():
155+ // Closing the stream unblocks the admitted read; the worker is joined so
156+ // no observation goroutine outlives its attempt.
157+ _ = logical.Close()
158+ <-done
159+ return nil, ctx.Err()
160+ }
161+}
162+
163+func (p *brokerRemoteProbe) request(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerOpenStreamRequest, error) {
164+ if p.hosts == nil {
94165 return ports.BrokerOpenStreamRequest{}, errors.New("vev: remote probe has no policy authority")
95166 }
96- policy, ok := p.policy(registration.Endpoint)
97- if !ok {
98- return ports.BrokerOpenStreamRequest{}, errors.New("vev: remote probe endpoint is not configured")
167+ record, ok, err := p.hosts.LookupHost(ctx, registration.Endpoint)
168+ if err != nil {
169+ return ports.BrokerOpenStreamRequest{}, err
170+ }
171+ if !ok || !record.Registration.Equal(registration) {
172+ return ports.BrokerOpenStreamRequest{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "unknown or stale probe registration"}
99173 }
174+ policy := record.Policy
100175 connection, err := randomConnectionID()
101176 if err != nil {
102177 return ports.BrokerOpenStreamRequest{}, err
@@ -109,6 +184,9 @@ func (p *brokerRemoteProbe) request(registration domain.RemoteRegistration) (por
109184 Epoch: p.epoch, Purpose: ports.BrokerStreamObservation,
110185 Connection: connection, Stream: ports.BrokerStreamID(stream),
111186 Endpoint: registration.Endpoint, Registration: registration, Policy: policy,
187+ // Observation must never start a stopped daemon, so it always carries
188+ // the existing-only authorization.
189+ StartMode: ports.BrokerDaemonExistingOnly,
112190 }, nil
113191 }
114192
M internal/app/broker_remote_probe_test.go +9 -5
@@ -15,12 +15,10 @@ func TestBrokerRemoteProbeRequestIsObservationOnly(t *testing.T) {
1515 policy := ports.BrokerPolicy{ProtocolVersion: protocol.Version, CatalogSchemaVersion: 3, Transport: "stdio", Trust: "ssh", Launch: "explicit", Isolation: "user", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}
1616 probe := &brokerRemoteProbe{
1717 epoch: 7,
18- policy: func(endpoint string) (ports.BrokerPolicy, bool) {
19- return policy, endpoint == registration.Endpoint
20- },
18+ hosts: &routeTestHosts{record: ports.BrokerHostRecord{Registration: registration, Policy: policy}, found: true},
2119 }
2220
23- request, err := probe.request(registration)
21+ request, err := probe.request(context.Background(), registration)
2422 require.NoError(t, err)
2523 require.NoError(t, request.Validate())
2624 require.Equal(t, ports.BrokerStreamObservation, request.Purpose)
@@ -28,10 +26,16 @@ func TestBrokerRemoteProbeRequestIsObservationOnly(t *testing.T) {
2826 require.Empty(t, request.Env)
2927 require.Equal(t, registration, request.Registration)
3028 require.Equal(t, policy, request.Policy)
29+ // Observation must never start the target: the request carries the
30+ // existing-only authorization, and a spawn-capable one would be refused.
31+ require.Equal(t, ports.BrokerDaemonExistingOnly, request.StartMode)
32+ startable := request
33+ startable.StartMode = ports.BrokerDaemonStartIfNeeded
34+ require.Error(t, startable.Validate())
3135 }
3236
3337 func TestBrokerRemoteProbeRejectsUnconfiguredEndpointBeforeDial(t *testing.T) {
34- probe := &brokerRemoteProbe{epoch: 7, policy: func(string) (ports.BrokerPolicy, bool) { return ports.BrokerPolicy{}, false }}
38+ probe := &brokerRemoteProbe{epoch: 7, hosts: &routeTestHosts{}}
3539 _, err := probe.Probe(context.Background(), domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1})
3640 require.ErrorContains(t, err, "incomplete broker remote probe")
3741 }
A internal/app/broker_routes.go +65 -0
@@ -0,0 +1,65 @@
1+package app
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/vev/internal/adapters/brokerconfig"
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// brokerRoutes splits authority deliberately: local routing is provisioned by
11+// composition; remote routing is read from committed registry membership, never
12+// from its advisory snapshot or the startup import. hosts is wired before Run.
13+type brokerRoutes struct {
14+ local ports.BrokerRouteAuthority
15+ hosts ports.BrokerHostAuthorityReader
16+}
17+
18+func (r *brokerRoutes) ResolveDialTarget(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerDialTarget, error) {
19+ if err := ctx.Err(); err != nil {
20+ return ports.BrokerDialTarget{}, err
21+ }
22+ if request.Local {
23+ return r.local.ResolveDialTarget(ctx, request)
24+ }
25+ record, found, err := r.hosts.LookupHost(ctx, request.Endpoint)
26+ if err != nil {
27+ return ports.BrokerDialTarget{}, err
28+ }
29+ if !found || !record.Registration.Equal(request.Registration) {
30+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "unknown or stale registration"}
31+ }
32+ if !record.Policy.Compatible(request.Policy) {
33+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy}
34+ }
35+ route, err := brokerconfig.RouteFromSpec(record.Route)
36+ if err != nil {
37+ return ports.BrokerDialTarget{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
38+ }
39+ target := ports.BrokerDialTarget{
40+ Fence: ports.BrokerEndpointFence{Registration: record.Registration},
41+ Policy: record.Policy, Address: route.Address(), StartMode: request.StartMode,
42+ ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: record.Identity, Bound: record.Identity != ""},
43+ }
44+ return target, target.Validate()
45+}
46+
47+// remoteRoute rechecks the whole target immediately before dialing. A removed,
48+// replaced, rebound, or rerouted record cannot authorize a stale dial target.
49+func (r *brokerRoutes) remoteRoute(ctx context.Context, target ports.BrokerDialTarget) (brokerconfig.Route, error) {
50+ record, found, err := r.hosts.LookupHost(ctx, target.Fence.Registration.Endpoint)
51+ if err != nil {
52+ return brokerconfig.Route{}, err
53+ }
54+ if !found || !record.Registration.Equal(target.Fence.Registration) || record.Policy != target.Policy || record.Identity != target.ExpectedIdentity.Identity {
55+ return brokerconfig.Route{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "route authority changed"}
56+ }
57+ route, err := brokerconfig.RouteFromSpec(record.Route)
58+ if err != nil {
59+ return brokerconfig.Route{}, err
60+ }
61+ if route.Address() != target.Address {
62+ return brokerconfig.Route{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "route authority changed"}
63+ }
64+ return route, nil
65+}
A internal/app/broker_routes_test.go +212 -0
@@ -0,0 +1,212 @@
1+package app
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/adapters/brokerconfig"
11+ "github.com/bnema/vev/internal/adapters/brokeripc"
12+ "github.com/bnema/vev/internal/adapters/brokerstore"
13+ "github.com/bnema/vev/internal/adapters/clock"
14+ "github.com/bnema/vev/internal/adapters/daemonmux"
15+ "github.com/bnema/vev/internal/adapters/ipc"
16+ "github.com/bnema/vev/internal/domain"
17+ "github.com/bnema/vev/internal/ports"
18+ "github.com/bnema/vev/internal/usecase/broker"
19+ "github.com/bnema/vev/pkg/safedir"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+type routeTestHosts struct {
24+ record ports.BrokerHostRecord
25+ found bool
26+}
27+
28+func (h *routeTestHosts) LookupHost(ctx context.Context, endpoint string) (ports.BrokerHostRecord, bool, error) {
29+ return h.record, h.found && endpoint == h.record.Registration.Endpoint, ctx.Err()
30+}
31+
32+func TestBrokerRoutesDurableFirstAddAndRestart(t *testing.T) {
33+ ctx := context.Background()
34+ dir := filepath.Join(t.TempDir(), "state")
35+ store, err := brokerstore.Open(brokerstore.Options{Dir: dir})
36+ require.NoError(t, err)
37+ defer func() { require.NoError(t, store.Close()) }()
38+ routes := &brokerRoutes{}
39+ probe := &brokerRemoteProbe{epoch: 1, routes: routes}
40+ registry, err := broker.NewRegistryWithConfig(1, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable})
41+ require.NoError(t, err)
42+ routes.hosts = registry
43+ probe.hosts = registry
44+ policy := remoteBrokerPolicy("stdio")
45+ reg, err := registry.AddHost(ctx, "user@example.test", policy)
46+ require.NoError(t, err)
47+ request, err := probe.request(ctx, reg)
48+ require.NoError(t, err)
49+ target, err := routes.ResolveDialTarget(ctx, request)
50+ require.NoError(t, err)
51+ require.False(t, target.ExpectedIdentity.Bound)
52+ route, err := routes.remoteRoute(ctx, target)
53+ require.NoError(t, err)
54+ require.Equal(t, brokerconfig.RouteSSHStdio, route.Kind())
55+ require.Equal(t, []string{"vev", brokerMuxStdioCommand, "--production"}, route.Argv())
56+ identity, err := registry.BindAuthenticatedIdentity(ctx, ports.BrokerIdentityBindingRequest{Fence: target.Fence, Policy: policy, Identity: "daemon-test"})
57+ require.NoError(t, err)
58+ _, err = routes.remoteRoute(ctx, target)
59+ require.Error(t, err, "an unbound target cannot bypass a newly committed identity")
60+ target, err = routes.ResolveDialTarget(ctx, request)
61+ require.NoError(t, err)
62+ require.Equal(t, identity, target.ExpectedIdentity.Identity)
63+ require.True(t, target.ExpectedIdentity.Bound)
64+ cancelled, cancel := context.WithCancel(ctx)
65+ cancel()
66+ registry.Run(cancelled) // flush advisory writes before closing durable ownership
67+ require.NoError(t, store.Close())
68+ store, err = brokerstore.Open(brokerstore.Options{Dir: dir})
69+ require.NoError(t, err)
70+ restarted, err := broker.NewRegistryWithConfig(2, store, probe, clock.New(), nil, broker.RegistryConfig{MembershipMode: broker.MembershipMutable})
71+ require.NoError(t, err)
72+ routes.hosts = restarted
73+ recovered, err := routes.ResolveDialTarget(ctx, request)
74+ require.NoError(t, err)
75+ require.Equal(t, target, recovered)
76+ restarted.Run(cancelled)
77+}
78+
79+func TestBrokerRoutesRejectChangedAuthority(t *testing.T) {
80+ reg := domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1}
81+ policy := remoteBrokerPolicy("stdio")
82+ spec, err := ports.BrokerRouteForTransport(policy.Transport, reg.Endpoint)
83+ require.NoError(t, err)
84+ original := ports.BrokerHostRecord{Registration: reg, Policy: policy, Route: spec}
85+ for _, tc := range []struct {
86+ name string
87+ change func(*routeTestHosts)
88+ }{
89+ {"removed", func(h *routeTestHosts) { h.found = false }},
90+ {"readded", func(h *routeTestHosts) { h.record.Registration.Incarnation[0]++ }},
91+ {"policy", func(h *routeTestHosts) { h.record.Policy.Trust = "other" }},
92+ {"route", func(h *routeTestHosts) { h.record.Route.Target = "other@example.test" }},
93+ {"identity", func(h *routeTestHosts) { h.record.Identity = "other-daemon" }},
94+ } {
95+ t.Run(tc.name, func(t *testing.T) {
96+ hosts := &routeTestHosts{record: original, found: true}
97+ routes := &brokerRoutes{hosts: hosts}
98+ req := ports.BrokerOpenStreamRequest{Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly}
99+ target, err := routes.ResolveDialTarget(context.Background(), req)
100+ require.NoError(t, err)
101+ tc.change(hosts)
102+ _, err = routes.remoteRoute(context.Background(), target)
103+ require.Error(t, err)
104+ })
105+ }
106+ ctx, cancel := context.WithCancel(context.Background())
107+ cancel()
108+ _, err = (&brokerRoutes{}).ResolveDialTarget(ctx, ports.BrokerOpenStreamRequest{})
109+ require.ErrorIs(t, err, context.Canceled)
110+}
111+
112+func TestCanonicalBrokerRoutesSelectProductionHelpers(t *testing.T) {
113+ for _, transport := range []string{"ssh-stdio", "ssh-quic"} {
114+ t.Run(transport, func(t *testing.T) {
115+ spec, err := ports.BrokerRouteForTransport(transport, "user@example.test")
116+ require.NoError(t, err)
117+ cmd, err := parseArgs(spec.Argv[1:])
118+ require.NoError(t, err)
119+ require.True(t, cmd.brokerMux.production)
120+ require.Equal(t, ports.BrokerDaemonExistingOnly, cmd.brokerMux.startMode)
121+ for _, args := range [][]string{{"--production", "--production"}, {"--production", "--offline-root", "/tmp/root"}, {"--offline-root", "/tmp/root", "--production"}} {
122+ _, err = parseBrokerMuxArgs(spec.Argv[1], cmd.kind, args)
123+ require.Error(t, err)
124+ }
125+ })
126+ }
127+}
128+
129+// The real production composition starts with no remote import. The first IPC
130+// add must succeed and the same running registry must retain it across restart.
131+func TestProductionBrokerFirstHostAdd(t *testing.T) {
132+ t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t, "vevroute"))
133+ t.Setenv("XDG_STATE_HOME", t.TempDir())
134+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
135+ require.NoError(t, safedir.EnsurePrivate(filepath.Dir(productionBrokerConfigPath())))
136+ raw, err := json.Marshal(map[string]any{"marker": brokerconfig.Marker, "registrations": []any{}})
137+ require.NoError(t, err)
138+ require.NoError(t, os.WriteFile(productionBrokerConfigPath(), raw, 0600))
139+ var registered domain.RemoteRegistration
140+ for i := 0; i < 2; i++ {
141+ deps, ready := testBrokerServeDeps(newSandboxClock())
142+ ctx, cancel := context.WithCancel(context.Background())
143+ done := runSandbox(ctx, brokerServeOptions{production: true}, deps)
144+ socket := awaitSandboxReady(t, ready, done)
145+ service, err := brokeripc.NewConnector(socket, brokeripc.Config{}).Connect(ctx)
146+ require.NoError(t, err)
147+ reg, err := service.AddHost(ctx, "user@127.0.0.1:1", remoteBrokerPolicy("stdio"))
148+ require.NoError(t, err)
149+ if i == 0 {
150+ registered = reg
151+ } else {
152+ require.Equal(t, registered, reg)
153+ }
154+ require.NoError(t, service.Close())
155+ awaitSandboxCancel(t, cancel, done)
156+ }
157+}
158+
159+func TestBrokerDynamicConnectorDialsDurableRouteWithoutConfigEntry(t *testing.T) {
160+ policy := brokerTestPolicy()
161+ fixture := newBrokerMuxFixture(t, policy)
162+ reg := brokerTestRegistration()
163+ hosts := &routeTestHosts{found: true, record: ports.BrokerHostRecord{
164+ Registration: reg, Policy: policy, Route: ports.BrokerRouteSpec{Kind: ports.BrokerRouteUnix, Path: fixture.route},
165+ }}
166+ routes := &brokerRoutes{hosts: hosts}
167+ target, err := routes.ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{
168+ Endpoint: reg.Endpoint, Registration: reg, Policy: policy, StartMode: ports.BrokerDaemonExistingOnly,
169+ })
170+ require.NoError(t, err)
171+ connector, err := brokerDynamicMuxConnector(nil, routes, nil)
172+ require.NoError(t, err)
173+ physical, err := connector.Connect(context.Background(), target)
174+ require.NoError(t, err)
175+ require.Equal(t, ports.BrokerDaemonIdentity(brokerTestIdentity), physical.Identity())
176+ require.NoError(t, physical.Close())
177+ hosts.found = false
178+ _, err = connector.Connect(context.Background(), target)
179+ require.Error(t, err)
180+}
181+
182+func TestProductionMuxHelperExistingOnlyNeverSpawns(t *testing.T) {
183+ t.Setenv("XDG_RUNTIME_DIR", shortTempDir(t, "vevhelper"))
184+ previous := localDaemonStarter
185+ defer func() { localDaemonStarter = previous }()
186+ dials, spawns := 0, 0
187+ localDaemonStarter = brokerDaemonStarter{
188+ dial: func(_ context.Context, path string) (daemonmux.RawFramedTransport, error) {
189+ dials++
190+ require.Equal(t, daemonmux.SocketPath(ipc.SocketDir()), path)
191+ return nil, os.ErrNotExist
192+ },
193+ spawn: func() error { spawns++; return nil },
194+ }
195+ _, err := dialBrokerMuxHelper(context.Background(), brokerMuxOptions{production: true, startMode: ports.BrokerDaemonExistingOnly})
196+ require.ErrorIs(t, err, os.ErrNotExist)
197+ require.Equal(t, 1, dials)
198+ require.Zero(t, spawns)
199+}
200+
201+func TestBrokerRoutesLocalRemainsConfigured(t *testing.T) {
202+ config, binding := testLocalSandboxConfigForCarriage(t, filepath.Join(t.TempDir(), "daemon.sock"))
203+ routes := &brokerRoutes{local: config.Resolver()} // no remote authority at all
204+ request := ports.BrokerOpenStreamRequest{Local: true, Policy: binding.Policy, StartMode: ports.BrokerDaemonExistingOnly}
205+ target, err := routes.ResolveDialTarget(context.Background(), request)
206+ require.NoError(t, err)
207+ require.Equal(t, binding.Route.Address(), target.Address)
208+ require.Equal(t, binding.Identity, target.ExpectedIdentity.Identity)
209+ request.Policy.Trust = "caller-selected"
210+ _, err = routes.ResolveDialTarget(context.Background(), request)
211+ require.Error(t, err)
212+}
D internal/app/client_hosts.go +0 -91
@@ -1,91 +0,0 @@
1-package app
2-
3-import (
4- "context"
5- "errors"
6- "fmt"
7- "log/slog"
8-
9- remoteadapter "github.com/bnema/vev/internal/adapters/remote"
10- "github.com/bnema/vev/internal/adapters/sessionwire"
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/ports"
13- "github.com/bnema/vev/internal/usecase/remotes"
14-)
15-
16-// This file composes the endpoint bindings a launching client reuses across
17-// handoffs. Remote discovery and catalogue publication remain daemon-owned.
18-
19-// clientEndpointFactory resolves one endpoint into the carriage the client
20-// reuses for every session on that endpoint. Transport mode, launch
21-// configuration, and endpoint environment are frozen here by the composition
22-// root; the client only receives the resulting typed dialer.
23-type clientEndpointFactory struct {
24- factory remoteDialerForTarget
25- mode remoteadapter.TransportMode
26- // modeErr is the transport-mode validation failure, reported for every
27- // endpoint so an invalid configured mode fails a handoff instead of
28- // silently selecting another carriage.
29- modeErr error
30- // allowlistErr is the launch-allowlist validation failure, reported for
31- // every endpoint so a malformed configured allowlist fails remote
32- // resolution instead of admitting an unvetted endpoint. It is deliberately
33- // not fatal to construction: a local attach never resolves an endpoint and
34- // must keep working with a malformed allowlist in the environment.
35- allowlistErr error
36- environment func(string) []string
37- allowed map[string]struct{}
38- // restricted reports whether an allowlist was configured at all. A
39- // configured allowlist is authoritative even when it lists nothing.
40- restricted bool
41- log *slog.Logger
42-}
43-
44-var _ ports.RemoteEndpointFactory = clientEndpointFactory{}
45-
46-func (f clientEndpointFactory) ResolveEndpoint(ctx context.Context, endpoint string) (ports.RemoteEndpointBinding, error) {
47- if err := ctx.Err(); err != nil {
48- return ports.RemoteEndpointBinding{}, err
49- }
50- if err := domain.ValidateRemoteHostTarget(endpoint); err != nil {
51- return ports.RemoteEndpointBinding{}, fmt.Errorf("vev: invalid remote endpoint: %w", err)
52- }
53- if f.modeErr != nil {
54- return ports.RemoteEndpointBinding{}, f.modeErr
55- }
56- if f.allowlistErr != nil {
57- return ports.RemoteEndpointBinding{}, f.allowlistErr
58- }
59- if f.restricted && !allowlistedRemoteEndpoint(f.allowed, endpoint) {
60- return ports.RemoteEndpointBinding{}, fmt.Errorf("vev: remote endpoint %q is not allowed", endpoint)
61- }
62- if f.factory == nil {
63- return ports.RemoteEndpointBinding{}, errors.New("vev: remote dialer factory unavailable")
64- }
65- // The carriage is endpoint-scoped: the session name only labels logs, so a
66- // binding resolved for one session serves every later session on it.
67- dialer, err := f.factory(endpoint, "", f.mode, f.log)
68- if err != nil {
69- return ports.RemoteEndpointBinding{}, err
70- }
71- if dialer == nil {
72- return ports.RemoteEndpointBinding{}, errors.New("vev: remote dialer factory returned no dialer")
73- }
74- return ports.RemoteEndpointBinding{
75- Dialer: sessionwire.NewClientDialer(dialer),
76- Environment: clientEnvironment(f.environment, endpoint),
77- }, nil
78-}
79-
80-// newClientHostRegistry composes the runner-scoped endpoint cache. A malformed
81-// launch allowlist is stored on the factory and deferred to ResolveEndpoint, so
82-// it can only fail a remote target's resolution, never construction or a local
83-// attach. Without a configured allowlist every endpoint is admitted.
84-func newClientHostRegistry(deps runAttachDeps, mode remoteadapter.TransportMode, modeErr error, log *slog.Logger) ports.ClientHostRegistry {
85- allowed, restricted, allowlistErr := remoteLaunchAllowlistFromEnv()
86- factory := clientEndpointFactory{
87- factory: deps.remoteDialerFactory, mode: mode, modeErr: modeErr, allowlistErr: allowlistErr,
88- environment: deps.remoteEnvironment, allowed: allowed, restricted: restricted, log: log,
89- }
90- return remotes.NewHostRegistry(factory)
91-}
D internal/app/client_hosts_test.go +0 -262
@@ -1,262 +0,0 @@
1-package app
2-
3-import (
4- "context"
5- "errors"
6- "log/slog"
7- "sync"
8- "testing"
9-
10- "github.com/stretchr/testify/require"
11-
12- remoteadapter "github.com/bnema/vev/internal/adapters/remote"
13- "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/internal/protocol"
15- "github.com/bnema/vev/internal/protocol/catalogue"
16- "github.com/bnema/vev/internal/protocol/wire"
17- "github.com/bnema/vev/internal/usecase/client"
18-)
19-
20-type clientHostsTestDialer struct{ name string }
21-
22-func (d clientHostsTestDialer) Dial(context.Context) (wire.Transport, error) {
23- return nil, errors.New("client hosts test dialer is never dialed")
24-}
25-
26-var _ wire.Dialer = clientHostsTestDialer{}
27-
28-func clientHostsEndpointFactory(factory remoteDialerForTarget, allowed map[string]struct{}, restricted bool, environment func(string) []string) clientEndpointFactory {
29- return clientEndpointFactory{
30- factory: factory, mode: remoteadapter.TransportQUIC, environment: environment,
31- allowed: allowed, restricted: restricted, log: slog.New(slog.DiscardHandler),
32- }
33-}
34-
35-func TestClientEndpointFactoryResolvesOneCarriagePerEndpoint(t *testing.T) {
36- factory := newRemoteDialerFactoryMock(t)
37- factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportQUIC, nil).Return(clientHostsTestDialer{name: "arch"}, nil).Once()
38- factory.EXPECT().DialerForRemote("cache", "", remoteadapter.TransportQUIC, nil).Return(clientHostsTestDialer{name: "cache"}, nil).Once()
39-
40- resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, func(endpoint string) []string {
41- return []string{"ENDPOINT=" + endpoint}
42- })
43- first, err := resolver.ResolveEndpoint(context.Background(), "arch")
44- require.NoError(t, err)
45- require.NotNil(t, first.Dialer)
46- require.Equal(t, []string{"ENDPOINT=arch"}, first.Environment)
47-
48- second, err := resolver.ResolveEndpoint(context.Background(), "cache")
49- require.NoError(t, err)
50- require.Equal(t, []string{"ENDPOINT=cache"}, second.Environment)
51-}
52-
53-func TestClientEndpointFactoryRefusesEndpointsOutsideTheAllowlist(t *testing.T) {
54- factory := newRemoteDialerFactoryMock(t)
55- resolver := clientHostsEndpointFactory(factory.DialerForRemote, map[string]struct{}{"arch": {}}, true, nil)
56-
57- _, err := resolver.ResolveEndpoint(context.Background(), "other")
58- require.ErrorContains(t, err, "not allowed")
59-
60- // A configured allowlist that lists nothing denies every endpoint, and the
61- // refusal never reaches the dialer factory.
62- resolver = clientHostsEndpointFactory(factory.DialerForRemote, map[string]struct{}{}, true, nil)
63- _, err = resolver.ResolveEndpoint(context.Background(), "arch")
64- require.ErrorContains(t, err, "not allowed")
65-
66- // Without a configured allowlist every valid endpoint resolves.
67- factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportQUIC, nil).Return(clientHostsTestDialer{}, nil).Once()
68- resolver = clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil)
69- _, err = resolver.ResolveEndpoint(context.Background(), "arch")
70- require.NoError(t, err)
71-}
72-
73-func TestClientEndpointFactoryRejectsInvalidEndpoints(t *testing.T) {
74- factory := newRemoteDialerFactoryMock(t)
75- resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil)
76- for _, endpoint := range []string{"", "bad\x1bhost", "spaces are not a host"} {
77- _, err := resolver.ResolveEndpoint(context.Background(), endpoint)
78- require.Error(t, err, "endpoint %q must be refused before a carriage is built", endpoint)
79- }
80-}
81-
82-func TestClientEndpointFactoryKeepsFactoryErrorsAuthoritative(t *testing.T) {
83- factory := newRemoteDialerFactoryMock(t)
84- want := errors.New("transport mode refused this endpoint")
85- factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportQUIC, nil).Return(nil, want).Once()
86- resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil)
87-
88- _, err := resolver.ResolveEndpoint(context.Background(), "arch")
89- require.ErrorIs(t, err, want)
90-}
91-
92-func TestClientEndpointFactoryHonoursContextCancellation(t *testing.T) {
93- factory := newRemoteDialerFactoryMock(t)
94- resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, nil)
95- ctx, cancel := context.WithCancel(context.Background())
96- cancel()
97- _, err := resolver.ResolveEndpoint(ctx, "arch")
98- require.ErrorIs(t, err, context.Canceled)
99-}
100-
101-// TestClientHostRegistryDefersMalformedLaunchAllowlist pins that a malformed
102-// configured allowlist is stored on the endpoint factory instead of failing
103-// registry construction: a local attach never resolves a remote endpoint, so
104-// construction and local attach keep working, while remote resolution stays
105-// fail-closed.
106-func TestClientHostRegistryDefersMalformedLaunchAllowlist(t *testing.T) {
107- t.Setenv(launchAllowedRemoteEndpointsEnv, "user@bad host")
108- factory := newRemoteDialerFactoryMock(t)
109-
110- registry := newClientHostRegistry(runAttachDeps{remoteDialerFactory: factory.DialerForRemote}, remoteadapter.TransportQUIC, nil, slog.New(slog.DiscardHandler))
111- require.NotNil(t, registry, "a malformed allowlist must not fail registry construction")
112-
113- _, err := registry.ResolveEndpoint(context.Background(), "arch")
114- require.ErrorContains(t, err, "invalid configured remote endpoint allowlist")
115-}
116-
117-// TestRunAttachWithDepsLocalAttachSurvivesMalformedLaunchAllowlist pins the
118-// local attach path end to end: a malformed remote allowlist in the
119-// environment is deferred, so a local session still attaches.
120-func TestRunAttachWithDepsLocalAttachSurvivesMalformedLaunchAllowlist(t *testing.T) {
121- t.Setenv(launchAllowedRemoteEndpointsEnv, "user@bad host")
122- factory := newRemoteDialerFactoryMock(t)
123- attached := false
124- err := runAttachWithDeps(context.Background(), protocol.IntentEphemeral, "", "", "", nil, runAttachDeps{
125- localDialer: func() wire.Dialer { return clientHostsTestDialer{name: "local"} },
126- remoteDialerFactory: factory.DialerForRemote,
127- runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error {
128- require.False(t, request.Remote)
129- attached = true
130- return nil
131- },
132- })
133- require.NoError(t, err, "a malformed remote allowlist must not break local attach")
134- require.True(t, attached)
135-}
136-
137-func TestClientEndpointFactoryKeepsNilEnvironmentDistinct(t *testing.T) {
138- factory := newRemoteDialerFactoryMock(t)
139- factory.EXPECT().DialerForRemote("arch", "", remoteadapter.TransportQUIC, nil).Return(clientHostsTestDialer{}, nil).Once()
140- factory.EXPECT().DialerForRemote("cache", "", remoteadapter.TransportQUIC, nil).Return(clientHostsTestDialer{}, nil).Once()
141- resolver := clientHostsEndpointFactory(factory.DialerForRemote, nil, false, func(endpoint string) []string {
142- if endpoint == "cache" {
143- return []string{}
144- }
145- return nil
146- })
147-
148- absent, err := resolver.ResolveEndpoint(context.Background(), "arch")
149- require.NoError(t, err)
150- require.Nil(t, absent.Environment)
151-
152- empty, err := resolver.ResolveEndpoint(context.Background(), "cache")
153- require.NoError(t, err)
154- require.NotNil(t, empty.Environment)
155- require.Empty(t, empty.Environment)
156-}
157-
158-// memorySeedCache is a durable-cache stand-in that records writes, so a test
159-// can prove the client monitor never writes the daemon's snapshot file.
160-type memorySeedCache struct {
161- mu sync.Mutex
162- entries []catalogue.RemoteCatalogCacheEntry
163- loads int
164- stores int
165- loadErr error
166-}
167-
168-func (c *memorySeedCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
169- c.mu.Lock()
170- defer c.mu.Unlock()
171- c.loads++
172- if c.loadErr != nil {
173- return nil, c.loadErr
174- }
175- return append([]catalogue.RemoteCatalogCacheEntry(nil), c.entries...), nil
176-}
177-
178-func (c *memorySeedCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
179- c.mu.Lock()
180- defer c.mu.Unlock()
181- c.stores++
182- c.entries = append([]catalogue.RemoteCatalogCacheEntry(nil), entries...)
183- return nil
184-}
185-
186-func (c *memorySeedCache) writeCount() int {
187- c.mu.Lock()
188- defer c.mu.Unlock()
189- return c.stores
190-}
191-
192-func (c *memorySeedCache) loadCount() int {
193- c.mu.Lock()
194- defer c.mu.Unlock()
195- return c.loads
196-}
197-
198-var _ ports.RemoteCatalogCache = (*memorySeedCache)(nil)
199-
200-// TestSeededCatalogCacheNeverWritesTheDurableSnapshot pins the client cache
201-// contract: it seeds from the durable entries once and keeps every later write
202-// in its own memory, so a client monitor cannot race the daemon's writer.
203-func TestSeededCatalogCacheNeverWritesTheDurableSnapshot(t *testing.T) {
204- seed := &memorySeedCache{entries: []catalogue.RemoteCatalogCacheEntry{{Host: "arch"}}}
205- client := remoteadapter.NewSeededCatalogCache(seed)
206-
207- seeded, err := client.Load()
208- require.NoError(t, err)
209- require.Len(t, seeded, 1)
210- require.Equal(t, "arch", seeded[0].Host)
211- require.Equal(t, 1, seed.loadCount(), "the durable cache is read once")
212-
213- require.NoError(t, client.Store([]catalogue.RemoteCatalogCacheEntry{{Host: "cache"}}))
214- require.Zero(t, seed.writeCount(), "the client cache must never write the durable snapshot")
215- require.Equal(t, 1, seed.loadCount(), "a write suppresses the seed read")
216-
217- stored, err := client.Load()
218- require.NoError(t, err)
219- require.Equal(t, "cache", stored[0].Host)
220-}
221-
222-func TestSeededCatalogCacheCopiesEntriesDefensively(t *testing.T) {
223- seed := &memorySeedCache{entries: []catalogue.RemoteCatalogCacheEntry{{
224- Host: "arch", Sessions: []catalogue.RemoteCatalogSession{{Name: "work", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}}},
225- }}}
226- client := remoteadapter.NewSeededCatalogCache(seed)
227-
228- first, err := client.Load()
229- require.NoError(t, err)
230- first[0].Sessions[0].Tabs[0].ID = "mutated"
231- first[0].Host = "mutated"
232-
233- second, err := client.Load()
234- require.NoError(t, err)
235- require.Equal(t, "arch", second[0].Host)
236- require.Equal(t, "tab-1", second[0].Sessions[0].Tabs[0].ID)
237-
238- seed.mu.Lock()
239- seed.entries[0].Host = "mutated"
240- seed.mu.Unlock()
241- third, err := client.Load()
242- require.NoError(t, err)
243- require.Equal(t, "arch", third[0].Host, "the client cache is not a live view of the durable file")
244-}
245-
246-func TestSeededCatalogCacheReportsASeedFailureOnce(t *testing.T) {
247- want := errors.New("durable cache is unreadable")
248- seed := &memorySeedCache{loadErr: want}
249- client := remoteadapter.NewSeededCatalogCache(seed)
250-
251- _, err := client.Load()
252- require.ErrorIs(t, err, want)
253- _, err = client.Load()
254- require.ErrorIs(t, err, want)
255- require.Equal(t, 1, seed.loadCount(), "a broken seed is reported, never retried")
256-
257- var nilCache *remoteadapter.SeededCatalogCache
258- entries, err := nilCache.Load()
259- require.NoError(t, err)
260- require.Empty(t, entries)
261- require.NoError(t, nilCache.Store(nil))
262-}
M internal/app/cmd.go +61 -12
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/vev/internal/ports"
1717 "github.com/bnema/vev/internal/protocol"
1818 "github.com/bnema/vev/internal/protocol/wire"
19+ clientusecase "github.com/bnema/vev/internal/usecase/client"
1920 commandusecase "github.com/bnema/vev/internal/usecase/command"
2021 "github.com/bnema/vev/internal/usecase/daemon"
2122 )
@@ -152,18 +153,20 @@ func (e *commandOutcomeUnknown) Unwrap() error { return e.detail }
152153 func (*commandOutcomeUnknown) Is(target error) bool { return target == errCommandOutcomeUnknown }
153154
154155 type cmdDeps struct {
155- stdout io.Writer
156- getenv func(string) string
157- dial func(context.Context, string) (wire.Transport, error)
158- ensure func(context.Context, string) (wire.Transport, error)
159- clock ports.Clock
156+ stdout io.Writer
157+ getenv func(string) string
158+ connect func(context.Context) (ports.BrokerService, error)
159+ dial func(context.Context, string) (wire.Transport, error)
160+ ensure func(context.Context, string) (wire.Transport, error)
161+ clock ports.Clock
160162 }
161163
162164 func runCmd(ctx context.Context, invocation cmdInvocation) error {
163165 return runCmdWithDeps(ctx, invocation, cmdDeps{
164- stdout: os.Stdout,
165- getenv: os.Getenv,
166- dial: realDial,
166+ stdout: os.Stdout,
167+ getenv: os.Getenv,
168+ connect: connectProductionBroker,
169+ dial: realDial,
167170 ensure: func(ctx context.Context, dir string) (wire.Transport, error) {
168171 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
169172 },
@@ -200,11 +203,28 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
200203 return usagef("too many command arguments")
201204 }
202205
203- dial := deps.dial
204- if invocation.slug == "remote-catalog" && deps.ensure != nil {
205- dial = deps.ensure
206+ if deps.connect != nil {
207+ service, err := deps.connect(ctx)
208+ if err != nil {
209+ return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
210+ }
211+ defer func() { _ = service.Close() }()
212+ commandClock := deps.clock
213+ if commandClock == nil {
214+ commandClock = clock.New()
215+ }
216+ operations, err := clientusecase.NewBrokerOperations(service, commandClock)
217+ if err != nil {
218+ return err
219+ }
220+ result, err := operations.Command(ctx, localBrokerOperationRoute(service.Snapshot()), request)
221+ if err != nil {
222+ return err
223+ }
224+ return renderCommandResult(deps.stdout, result)
206225 }
207- transport, err := dial(ctx, ipc.SocketDir())
226+
227+ transport, err := deps.dial(ctx, ipc.SocketDir())
208228 if err != nil {
209229 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
210230 }
@@ -284,3 +304,32 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
284304 }
285305 return nil
286306 }
307+
308+func renderCommandResult(stdout io.Writer, result protocol.CommandResult) error {
309+ if result.Outcome == protocol.CommandOutcomeUnknown {
310+ text := result.Text
311+ if text == "" {
312+ text = "daemon did not report a final outcome"
313+ }
314+ return &exitCoded{code: 3, err: &commandOutcomeUnknown{detail: errors.New(text)}}
315+ }
316+ if result.Outcome != protocol.CommandSucceeded {
317+ if result.Text == "" {
318+ result.Text = "command failed"
319+ }
320+ if result.Code == protocol.ErrInvalidCommandArgs {
321+ return &exitCoded{code: 2, err: errors.New(result.Text)}
322+ }
323+ return errors.New(result.Text)
324+ }
325+ if result.Output != "" {
326+ if _, err := io.WriteString(stdout, result.Output); err != nil {
327+ return err
328+ }
329+ if !strings.HasSuffix(result.Output, "\n") {
330+ _, err := fmt.Fprintln(stdout)
331+ return err
332+ }
333+ }
334+ return nil
335+}
M internal/app/cmd_test.go +10 -11
@@ -12,6 +12,7 @@ import (
1212 "time"
1313
1414 "github.com/bnema/vev/internal/adapters/sessionwire"
15+ "github.com/bnema/vev/internal/ports"
1516 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1617 "github.com/bnema/vev/internal/protocol"
1718 "github.com/bnema/vev/internal/protocol/wire"
@@ -207,23 +208,21 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
207208 }
208209 }
209210
210-func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) {
211- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
212- ensureCalls := 0
211+func TestRemoteCatalogCommandUsesBroker(t *testing.T) {
212+ stream := newSeamBrokerStream(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded})
213+ service := newSeamBrokerService(stream)
213214 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
214- stdout: io.Discard,
215- getenv: func(string) string { return "" },
215+ stdout: io.Discard,
216+ getenv: func(string) string { return "" },
217+ connect: func(context.Context) (ports.BrokerService, error) { return service, nil },
216218 dial: func(context.Context, string) (wire.Transport, error) {
217- t.Fatal("remote catalog used non-starting daemon dial")
219+ t.Fatal("remote catalog bypassed the broker")
218220 return nil, errors.New("unexpected dial")
219221 },
220- ensure: func(context.Context, string) (wire.Transport, error) {
221- ensureCalls++
222- return transport, nil
223- },
224222 })
225223 require.NoError(t, err)
226- require.Equal(t, 1, ensureCalls)
224+ require.Len(t, service.openedRequests(), 1)
225+ require.Equal(t, ports.BrokerStreamControl, service.openedRequests()[0].Purpose)
227226 }
228227
229228 func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
M internal/app/connectivity_matrix.go +61 -61
@@ -61,8 +61,14 @@ type connectivityEntry struct {
6161 Summary string
6262 Owner connectivityOwner
6363 // DirectDialDebt lists today's direct-daemon dial symbols that the
64- // cutover removes for broker-only entries. Empty for infra/local rows.
64+ // cutover removes for broker-only entries. Empty for infra/local rows and
65+ // for a broker-only row that has already completed its cutover.
6566 DirectDialDebt []string
67+ // Migrated marks a broker-only row whose cutover is complete: it keeps no
68+ // direct dial, no host-store write, and no persist mutation, so its debt
69+ // list is deliberately empty. It exists so a finished row is declared
70+ // explicitly instead of being mistaken for an unreviewed one.
71+ Migrated bool
6672 // HostStoreWrite reports a RemoteHostStore constructor/writer today.
6773 // Only the broker owns the host store after cutover.
6874 HostStoreWrite bool
@@ -77,28 +83,19 @@ type connectivityEntry struct {
7783 var connectivityMatrix = []connectivityEntry{
7884 {
7985 Kind: "kindAttach",
80- Summary: "interactive terminal attach/new/ephemeral, incl. remote attach, detached creation, attach preflight",
86+ Summary: "interactive terminal attach/new/ephemeral, incl. remote attach, detached creation, and the UI-driver/web path",
8187 Owner: connectivityBrokerOnly,
8288 DirectDialDebt: []string{
83- "localDaemonDialer.Dial (run.go)",
84- "dialOnlyLocalDialer.Dial inventory control (run.go)",
8589 "ensureDaemonWithLifecycle detached creation (run.go createDetachedLocalSession)",
86- "registry.ResolveEndpoint remote carriage (run.go runAttachWithDeps)",
87- "listSessionsWithDialer attach preflight (attach_preflight.go sessionExists)",
8890 },
89- Notes: "vev without arguments keeps creating an ephemeral session; creation is expressed as client navigation through the broker.",
91+ Notes: "The ordinary terminal path is already broker-only: runAttach translates the parsed CLI target into client.InitialNavigation/Resolver, connect-or-spawns the per-user broker, and delegates to the shared runBrokerClient (run.go, broker_client.go). The old direct-dialer attach composition and its attach preflight and client host registry are removed; the UI-driver and browser compositions delegate to the shared broker client. The only remaining debt is the nested-session detached creation path, which still dials the local daemon directly and is expressed as broker navigation for ordinary attach.",
9092 },
9193 {
92- Kind: "kindList",
93- Summary: "session list: local, per-host, and --all",
94- Owner: connectivityBrokerOnly,
95- DirectDialDebt: []string{
96- "realDial listSessionsWithDialer (run.go)",
97- "waitForDaemonOrLifecycle list path (run.go)",
98- "RemoteCatalogClient.List over SSH (remote_hosts.go listAllSessions/listOneRemoteHost)",
99- },
100- PersistMutation: true,
101- Notes: "Offline persist.LoadReadOnly list path becomes a daemon-owned operation through the broker; observation never attaches.",
94+ Kind: "kindList",
95+ Summary: "session list: local, per-host, and --all",
96+ Owner: connectivityBrokerOnly,
97+ Migrated: true,
98+ Notes: "Cutover complete: runList reaches the per-user broker through the private connectBroker seam (run.go) and reads BrokerOperations.List for the local list or runBrokerSnapshotList for per-host/--all. It keeps no persist read, no daemon dialer, and no direct-dial list path. Remote observation never attaches.",
10299 },
103100 {
104101 Kind: "kindHost",
@@ -109,15 +106,11 @@ var connectivityMatrix = []connectivityEntry{
109106 Notes: "Second host-store writer today; broker is the sole writer after cutover. Host list is store-only (no dial).",
110107 },
111108 {
112- Kind: "kindKill",
113- Summary: "kill one session, kill-all, explicit daemon-stop",
114- Owner: connectivityBrokerOnly,
115- DirectDialDebt: []string{
116- "waitForDaemonOrLifecycle realDial kill path (run.go)",
117- "forceStopDaemonFallback (force_stop.go)",
118- },
119- PersistMutation: true,
120- Notes: "Offline runOfflineNamedKill mutation becomes daemon-owned via broker. Kill-all purges sessions and leaves the daemon running; daemon-stop is the distinct explicit stop. Direct force-stop is offered only when the typed endpoint is unreachable, never after a request may have been delivered.",
109+ Kind: "kindKill",
110+ Summary: "kill one session, kill-all, explicit daemon-stop",
111+ Owner: connectivityBrokerOnly,
112+ Migrated: true,
113+ Notes: "Cutover complete: runKill and requestDaemonStop reach the per-user broker through the private connectBroker seam (run.go) and drive BrokerOperations.Kill/KillAll/StopDaemon. Daemon-stop uses ExistingOnly, so it can never start what it is stopping; kill-all purges sessions and leaves the daemon running. The offline persisted-mutation path and the direct force-stop fallback are removed, so no request is ever bypassed with a direct process signal.",
121114 },
122115 {
123116 Kind: "kindCmd",
@@ -130,28 +123,32 @@ var connectivityMatrix = []connectivityEntry{
130123 Notes: "Command tracker semantics unchanged; only the carriage moves behind the broker.",
131124 },
132125 {
133- Kind: "kindUIDriver",
134- Summary: "headless UI-driver attach",
135- Owner: connectivityBrokerOnly,
136- DirectDialDebt: []string{
137- "localDaemonDialer launch/local paths (ui_driver.go, run.go runAttachWithDeps)",
138- "configuredRemoteLaunches dialerFactory (ui_driver.go)",
139- },
140- Notes: "Launch-owned remote dialer and cleanup move behind the broker; UI-driver keeps no parallel carriage.",
126+ Kind: "kindUIDriver",
127+ Summary: "headless UI-driver attach",
128+ Owner: connectivityBrokerOnly,
129+ Migrated: true,
130+ Notes: "Cutover complete: the driver composes the process' production broker connector and the shared terminal initial-navigation translation through runUIDriverClient. It keeps no launch configuration, no dialer, no endpoint, and no daemon ownership.",
141131 },
142132 {
143- Kind: "kindWebDaemon",
144- Summary: "browser gateway launcher",
145- Owner: connectivityBrokerOnly,
146- DirectDialDebt: []string{"gateway session path via runAttachWithDeps (web_daemon.go)"},
147- Notes: "Launcher mechanics stay local; gateway session traffic goes through the broker like any client.",
133+ Kind: "kindUIRemoteCleanup",
134+ Summary: "UI fixture cleanup through broker control",
135+ Owner: connectivityBrokerOnly,
136+ Migrated: true,
137+ Notes: "Cleanup uses the broker-owned daemon-stop operation and keeps no direct daemon carriage.",
148138 },
149139 {
150- Kind: "kindWebServe",
151- Summary: "browser gateway server",
152- Owner: connectivityBrokerOnly,
153- DirectDialDebt: []string{"gateway session path via runAttachWithDeps (web_daemon.go)"},
154- Notes: "One client runner per WebSocket; no bypass of the broker façade.",
140+ Kind: "kindWebDaemon",
141+ Summary: "browser gateway launcher",
142+ Owner: connectivityBrokerOnly,
143+ Migrated: true,
144+ Notes: "Launcher mechanics stay local. Cutover complete: the gateway server composes the shared runBrokerClient over the process' production broker connector (web_daemon.go runWebTerminalClient), so gateway session traffic goes through the broker like any client. The launcher keeps no dialer, no session carriage, and no daemon ownership.",
145+ },
146+ {
147+ Kind: "kindWebServe",
148+ Summary: "browser gateway server",
149+ Owner: connectivityBrokerOnly,
150+ Migrated: true,
151+ Notes: "Cutover complete: every authenticated WebSocket owns exactly one runBrokerClient over the shared production broker connector, with the virtual webterm.Terminal as that run's terminal, the no-argument local ephemeral creation as its closed initial navigation, and the standard presentation callbacks. It keeps no direct daemon dialer, no sessionwire carriage, no remote factory, and no launch configuration; disconnect cancels only that supervisor's service and logical stream.",
155152 },
156153 {
157154 Kind: "kindDaemon",
@@ -183,27 +180,18 @@ var connectivityMatrix = []connectivityEntry{
183180 Owner: connectivityTransportInfra,
184181 Notes: "Blind proxy may forward raw envelopes; exposes no bytes to use cases and owns no sessions.",
185182 },
186- {
187- Kind: "kindRemotePreview",
188- Summary: "_remote-preview SSH-side preview carriage",
189- Owner: connectivityTransportInfra,
190- DirectDialDebt: []string{
191- "ipc.DialContext direct local dial (remote_preview.go)",
192- },
193- Notes: "Far-side helper only. Client-facing previews flow through broker snapshots after cutover; this helper never becomes a client façade. Direct-dial debt is recorded here rather than as broker debt because the helper itself stays infra.",
194- },
195- {
196- Kind: "kindUIRemoteCleanup",
197- Summary: "_ui-cleanup remote-side launch cleanup",
198- Owner: connectivityTransportInfra,
199- Notes: "Scoped remote cleanup; never launches observers or brokers recursively.",
200- },
201183 {
202184 Kind: "kindWebRenew",
203185 Summary: "browser token renewal",
204186 Owner: connectivityLocalOnly,
205187 Notes: "Local control only; no daemon or session connectivity.",
206188 },
189+ {
190+ Kind: "kindBrokerReady",
191+ Summary: "hidden dial-only broker readiness probe",
192+ Owner: connectivityLocalOnly,
193+ Notes: "Dials only the selected broker IPC socket, registers, subscribes, and reads snapshots; it never ensures, spawns, opens a logical stream, reconciles, or mutates.",
194+ },
207195 {
208196 Kind: "kindHelp",
209197 Summary: "usage text",
@@ -222,6 +210,12 @@ var connectivityMatrix = []connectivityEntry{
222210 Owner: connectivityLocalOnly,
223211 Notes: "Foreground sandbox process over its own private root: no production runtime/state, no daemon dial, no RemoteHostStore, and no session persistence. It composes the broker under a temporary offline config and does not become a client façade or an ordinary command.",
224212 },
213+ {
214+ Kind: "kindProductionBrokerServe",
215+ Summary: "hidden production broker server",
216+ Owner: connectivityLocalOnly,
217+ Notes: "Foreground production broker using only productionBrokerLayout and productionBrokerConfigPath; it accepts no offline root.",
218+ },
225219 {
226220 Kind: "kindBrokerClient",
227221 Summary: "hidden _broker-client autonomous offline client harness",
@@ -234,6 +228,12 @@ var connectivityMatrix = []connectivityEntry{
234228 Owner: connectivityLocalOnly,
235229 Notes: "Process mechanics only, scoped to one operator-supplied offline root: it starts _broker-serve in a new session and exits, like the daemon launcher, and never dials a daemon, owns the host store, or mutates persistence.",
236230 },
231+ {
232+ Kind: "kindProductionBrokerLauncher",
233+ Summary: "hidden detached production broker launcher",
234+ Owner: connectivityLocalOnly,
235+ Notes: "Process mechanics for the production broker only; it accepts no offline root and starts the production serve role in a new session.",
236+ },
237237 {
238238 Kind: "kindBrokerStatus",
239239 Summary: "hidden _broker-status offline broker probe and connect-or-spawn",
@@ -247,7 +247,7 @@ var connectivityMatrix = []connectivityEntry{
247247 DirectDialDebt: []string{
248248 "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)",
249249 },
250- Notes: "Remote-side helper over one operator-supplied offline root: it bridges its own stdio to the single provisioned private Unix daemonmux carriage. It never starts a broker, observer, or ordinary daemon, never dials the production daemon socket, and never fabricates a daemon incarnation. Direct-dial debt is recorded here rather than as broker debt because the helper itself stays transport infra.",
250+ Notes: "Remote-side helper over one operator-supplied offline root: it bridges its own stdio to the single provisioned private Unix daemonmux carriage. It never starts a broker, observer, or recursive helper, never dials the production daemon socket, and never fabricates a daemon incarnation. It starts the daemon behind that carriage only under the propagated --daemon-start if-needed authorization and only when its own provisioned policy permits launching; otherwise it dials only an existing carriage. Direct-dial debt is recorded here rather than as broker debt because the helper itself stays transport infra.",
251251 },
252252 {
253253 Kind: "kindBrokerMuxQUICBootstrap",
@@ -262,7 +262,7 @@ var connectivityMatrix = []connectivityEntry{
262262 DirectDialDebt: []string{
263263 "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)",
264264 },
265- Notes: "Mints one ephemeral authenticated QUIC server, admits exactly one carriage, and bridges it to the single provisioned private Unix daemonmux carriage. It never dials the production daemon socket, never starts a daemon, and never fabricates a daemon incarnation.",
265+ Notes: "Mints one ephemeral authenticated QUIC server, admits exactly one carriage, and bridges it to the single provisioned private Unix daemonmux carriage. It never dials the production daemon socket, never fabricates a daemon incarnation, and starts the daemon behind that carriage only under the propagated --daemon-start if-needed authorization and its own provisioned launch policy.",
266266 },
267267 }
268268
M internal/app/connectivity_matrix_test.go +97 -16
@@ -84,6 +84,10 @@ func TestConnectivityMatrixOwnershipRules(t *testing.T) {
8484 switch entry.Owner {
8585 case connectivityBrokerOnly:
8686 hasDebt := len(entry.DirectDialDebt) > 0 || entry.HostStoreWrite || entry.PersistMutation
87+ if entry.Migrated {
88+ require.False(t, hasDebt, "migrated broker-only %q must declare no remaining migration debt", entry.Kind)
89+ break
90+ }
8791 require.True(t, hasDebt, "broker-only %q must declare migration debt (direct dial, host-store write, or persist mutation)", entry.Kind)
8892 case connectivityTransportInfra:
8993 require.False(t, entry.HostStoreWrite, "infra %q must never own the host store", entry.Kind)
@@ -144,23 +148,48 @@ func TestConnectivityMatrixCurrentDebtPresent(t *testing.T) {
144148 require.Contains(t, body, symbol, "expected debt symbol %q in %q", symbol, file)
145149 }
146150
147- assertContains("type localDaemonDialer struct", "run.go")
148- assertContains("type dialOnlyLocalDialer struct", "run.go")
149- assertContains("func (d localDaemonDialer) Dial", "run.go")
150- assertContains("func (d dialOnlyLocalDialer) Dial", "run.go")
151151 assertContains("func ensureDaemonWithLifecycle", "spawn.go")
152- assertContains("func waitForDaemonOrLifecycle", "spawn.go")
153152 assertContains("func realDial", "spawn.go")
154153 assertContains("func realSpawn", "spawn.go")
155- assertContains("func (d remoteHostDeps) hostStore", "remote_hosts.go")
156- assertContains("func hostAdd", "remote_hosts.go")
157- assertContains("func hostRm", "remote_hosts.go")
158- assertContains("func listSessionsWithDialer", "run.go")
159- assertContains("func sessionExists", "attach_preflight.go")
160- assertContains("func runOfflineNamedKill", "run.go")
161- assertContains("persist.LoadReadOnly", "run.go")
162- assertContains("persist.OpenOrCreate", "run.go")
163- assertContains("func newClientHostRegistry", "client_hosts.go")
154+}
155+
156+// TestConnectivityMatrixListKillStopAreBrokerOnly pins the P7.4f cutover for
157+// the list/kill/stop rows: each reaches the per-user broker through the private
158+// connectBroker seam and keeps no removed bypass, so runList and runKill/stop
159+// can never read durable state or signal a process directly.
160+func TestConnectivityMatrixListKillStopAreBrokerOnly(t *testing.T) {
161+ t.Parallel()
162+
163+ sources := loadAppSources(t, false)
164+ runBody, ok := sources["run.go"]
165+ require.True(t, ok, "expected app source file run.go")
166+ require.Contains(t, runBody, "var connectBroker = connectProductionBroker",
167+ "run.go must keep the private connectBroker composition seam")
168+ require.Contains(t, runBody, "func runList", "run.go must keep runList")
169+ require.Contains(t, runBody, "func runKill", "run.go must keep runKill")
170+ require.Contains(t, runBody, "func requestDaemonStop", "run.go must keep requestDaemonStop")
171+
172+ forbiddenFiles := map[string][]string{
173+ "run.go": {
174+ "runOfflineNamedKill", "persist.LoadReadOnly", "forceStopDaemonFallback",
175+ "forceStopDaemonFn", "waitForDaemonOrLifecycle", "waitForLifecycleAvailability",
176+ },
177+ }
178+ for file, forbidden := range forbiddenFiles {
179+ body, ok := sources[file]
180+ require.True(t, ok, "expected app source file %q", file)
181+ for _, symbol := range forbidden {
182+ require.NotContains(t, body, symbol,
183+ "%s must not reintroduce the removed bypass %q", file, symbol)
184+ }
185+ }
186+
187+ // The whole force-stop subsystem is removed, so no production file may
188+ // resurrect an interactive process-signal fallback.
189+ for file, body := range sources {
190+ require.NotContains(t, body, "forceStop",
191+ "the force-stop subsystem must stay removed (%s)", file)
192+ }
164193 }
165194
166195 // TestConnectivityMatrixSingleHostStoreWriterSet fails when a new
@@ -178,8 +207,60 @@ func TestConnectivityMatrixSingleHostStoreWriterSet(t *testing.T) {
178207 }
179208 }
180209 sort.Strings(writers)
181- require.Equal(t, []string{"remote_hosts.go", "run.go"}, writers,
182- "host-store constructor sites changed; update the matrix debt set in the same change")
210+ require.Empty(t, writers,
211+ "the broker is the sole host-store owner; app must not construct another writer")
212+}
213+
214+// TestConnectivityMatrixReadyProbeIsLocalDialOnly pins the kindBrokerReady row
215+// and its production source: the hidden readiness probe is local-only, reaches
216+// no daemon or store, and its production body dials, subscribes, and reads only.
217+// It must never ensure or spawn a broker, open a logical stream, request a
218+// reconcile, or mutate membership; each of those would turn a machine probe
219+// into a client façade that changes the state it reports.
220+func TestConnectivityMatrixReadyProbeIsLocalDialOnly(t *testing.T) {
221+ t.Parallel()
222+
223+ owner, ok := connectivityOwnerFor("kindBrokerReady")
224+ require.True(t, ok, "the readiness probe must have a matrix row")
225+ require.Equal(t, connectivityLocalOnly, owner, "the readiness probe is dial-only local infrastructure")
226+ for _, entry := range connectivityMatrix {
227+ if entry.Kind != "kindBrokerReady" {
228+ continue
229+ }
230+ require.Empty(t, entry.DirectDialDebt, "a dial-only probe owes no connectivity migration")
231+ require.False(t, entry.HostStoreWrite, "the readiness probe never owns the host store")
232+ require.False(t, entry.PersistMutation, "the readiness probe never mutates session persistence")
233+ }
234+
235+ sources := loadAppSources(t, false)
236+ body, ok := sources["broker_ready.go"]
237+ require.True(t, ok, "expected app source file broker_ready.go")
238+
239+ forbidden := []struct {
240+ symbol string
241+ reason string
242+ }{
243+ {"ensureBrokerReady", "a readiness probe must never ensure a broker"},
244+ {"ensureDaemonWithLifecycle", "a readiness probe must never start a daemon"},
245+ {"spawnBrokerLauncher", "a readiness probe must never spawn a broker"},
246+ {"spawnProductionBrokerLauncher", "a readiness probe must never spawn a broker"},
247+ {"realDial", "a readiness probe dials the broker endpoint only"},
248+ {"OpenStream", "a readiness probe opens no logical stream"},
249+ {"RequestReconcile", "a readiness probe requests no reconcile"},
250+ {"AddHost", "a readiness probe mutates no membership"},
251+ {"RemoveHost", "a readiness probe mutates no membership"},
252+ {"UpdateHostPolicy", "a readiness probe mutates no membership"},
253+ {"NewFileHostStore", "a readiness probe owns no host store"},
254+ }
255+ for _, forbidden := range forbidden {
256+ require.NotContains(t, body, forbidden.symbol, "%s (%s)", forbidden.symbol, forbidden.reason)
257+ }
258+
259+ // Positive pins: the probe is one subscription over one snapshot loop, and
260+ // it classifies terminal failures without retrying them.
261+ for _, required := range []string{"func runBrokerReady", "func observeBrokerReady", "service.Subscribe()", "service.Snapshot()", "terminalReadyReason", "brokerReadyEndpointSecurity"} {
262+ require.Contains(t, body, required, "broker_ready.go must keep %s", required)
263+ }
183264 }
184265
185266 // commandKindsFromRunGo extracts the kind* const names from run.go so the
M internal/app/daemon_integration_test.go +4 -57
@@ -719,7 +719,9 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
719719 awaitText(t, p, sz, "TEMP")
720720 require.NoError(t, tr.Close())
721721
722- require.NoError(t, runKill(context.Background(), "", false, true))
722+ // An ephemeral session lives only while its daemon does; the explicit daemon
723+ // stop over the typed control wire ends that daemon.
724+ require.NoError(t, killDaemon(dir))
723725 select {
724726 case err := <-served:
725727 require.NoError(t, err)
@@ -731,7 +733,7 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
731733 sessions := listRemoteSessions(t, dir)
732734 require.Empty(t, sessions.Sessions)
733735
734- require.NoError(t, runKill(context.Background(), "", false, true))
736+ require.NoError(t, killDaemon(dir))
735737 select {
736738 case err := <-served2:
737739 require.NoError(t, err)
@@ -1007,61 +1009,6 @@ func TestMultipleClientsOneLifecycleOwner(t *testing.T) {
10071009 require.Zero(t, spawns.Load())
10081010 }
10091011
1010-func TestListWaitsForLifecycleOwner(t *testing.T) {
1011- stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
1012- t.Setenv("XDG_STATE_HOME", stateRoot)
1013- t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
1014- p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
1015- require.NoError(t, p.Close())
1016- owner, err := lifecycle.TryAcquire(ipc.SocketDir())
1017- require.NoError(t, err)
1018-
1019- done := make(chan error, 1)
1020- go func() { done <- runList(context.Background(), command{kind: kindList}) }()
1021- require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond)
1022- require.NoError(t, owner.Release())
1023- require.NoError(t, <-done)
1024-}
1025-
1026-func TestOfflineKillWaitsForLifecycleOwner(t *testing.T) {
1027- stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
1028- t.Setenv("XDG_STATE_HOME", stateRoot)
1029- t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
1030- p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
1031- now := time.Now().UnixNano()
1032- require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, CreatedAt: now, UpdatedAt: now}))
1033- require.NoError(t, p.Close())
1034- owner, err := lifecycle.TryAcquire(ipc.SocketDir())
1035- require.NoError(t, err)
1036-
1037- done := make(chan error, 1)
1038- go func() { done <- runKill(context.Background(), "named", false, false) }()
1039- require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond)
1040- require.NoError(t, owner.Release())
1041- require.NoError(t, <-done)
1042-}
1043-
1044-func TestKillDaemonWaitsForOwnershipTransfer(t *testing.T) {
1045- stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
1046- t.Setenv("XDG_STATE_HOME", stateRoot)
1047- t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
1048- owner, err := lifecycle.TryAcquire(ipc.SocketDir())
1049- require.NoError(t, err)
1050- _, served := startDaemonInDir(t, ipc.SocketDir())
1051-
1052- done := make(chan error, 1)
1053- go func() { done <- requestDaemonStop(context.Background()) }()
1054- select {
1055- case err := <-served:
1056- require.NoError(t, err)
1057- case <-time.After(time.Second):
1058- t.Fatal("daemon did not stop")
1059- }
1060- require.Never(t, func() bool { return len(done) != 0 }, 50*time.Millisecond, time.Millisecond)
1061- require.NoError(t, owner.Release())
1062- require.NoError(t, <-done)
1063-}
1064-
10651012 func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
10661013 runtimeDir := filepath.Join(t.TempDir(), "runtime")
10671014 stateDir := filepath.Join(t.TempDir(), "state")
D internal/app/dial_only_test.go +0 -51
@@ -1,51 +0,0 @@
1-package app
2-
3-import (
4- "context"
5- "testing"
6- "time"
7-
8- "github.com/stretchr/testify/require"
9-)
10-
11-// TestDialOnlyLocalDialerNeverStartsDaemon pins the D2 source contract: the
12-// inventory control dialer performs a bare socket dial with no ensure, spawn,
13-// or attachment path. Against an isolated directory it must fail fast with a
14-// dial error instead of implicitly starting a daemon.
15-func TestDialOnlyLocalDialerNeverStartsDaemon(t *testing.T) {
16- dir := t.TempDir()
17- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
18- defer cancel()
19- start := time.Now()
20- dialer := dialOnlyLocalDialer{dir: dir}
21- _, err := dialer.Dial(ctx)
22- if err == nil {
23- t.Fatal("dial-only control dial against an empty dir must fail")
24- }
25- if elapsed := time.Since(start); elapsed > 10*time.Second {
26- t.Fatalf("dial-only control dial took %v; must not retry or spawn", elapsed)
27- }
28- plain := dialOnlyLocalDialer{dir: dir, observer: nil}
29- if _, err := plain.Dial(context.Background()); err == nil {
30- t.Fatal("dial-only control dial without observer must also fail fast")
31- }
32-}
33-
34-// TestHybridMissingLocalSourceNeverStartsDaemon pins the hybrid degradation
35-// contract: when the dial-only local inventory source is unreachable, the
36-// composition must surface the dial failure rather than starting a daemon or
37-// creating a local attachment. The missing source degrades that source only.
38-func TestHybridMissingLocalSourceNeverStartsDaemon(t *testing.T) {
39- dir := t.TempDir()
40- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
41- defer cancel()
42- dialer := dialOnlyLocalDialer{dir: dir}
43- require.Error(t, func() error {
44- conn, err := dialer.Dial(ctx)
45- if err != nil {
46- return err
47- }
48- _ = conn.Close()
49- return nil
50- }(), "missing local control source must fail instead of starting a daemon")
51-}
D internal/app/force_stop.go +0 -299
@@ -1,299 +0,0 @@
1-package app
2-
3-import (
4- "bufio"
5- "context"
6- "errors"
7- "fmt"
8- "io"
9- "os"
10- "path/filepath"
11- "strings"
12- "syscall"
13- "time"
14-
15- "github.com/bnema/vev/internal/adapters/ipc"
16- "github.com/bnema/vev/internal/adapters/lifecycle"
17-)
18-
19-// Force-stopping is the interactive last resort for a daemon that still owns
20-// lifecycle.lock but cannot be stopped through the typed kill protocol, for
21-// example when `go install` replaced the binary while a `/proc/self/exe
22-// --daemon` process kept running under the name "exe". Ownership is never
23-// inferred from a process name: the candidate must be a same-user process that
24-// holds lifecycle.lock open and runs as a vev daemon, and its incarnation is
25-// re-verified before any escalation so a reused PID is never signalled.
26-
27-const (
28- // forceStopSIGTERMWait bounds how long SIGTERM has to release lifecycle
29- // ownership before the fallback escalates exactly once to SIGKILL.
30- forceStopSIGTERMWait = 5 * time.Second
31- // forceStopSIGKILLWait bounds how long SIGKILL has to release lifecycle
32- // ownership. Escalation never repeats.
33- forceStopSIGKILLWait = 5 * time.Second
34- // forceStopPollInterval is the ownership-transfer poll cadence.
35- forceStopPollInterval = 50 * time.Millisecond
36-)
37-
38-var (
39- // errForceStopNotApplicable reports that no force-stop was offered because
40- // lifecycle ownership was already free or no verified daemon process holds
41- // it. Callers keep their original diagnosis in that case.
42- errForceStopNotApplicable = errors.New("no verified daemon process holds lifecycle ownership")
43- // errForceStopDeclined reports an explicit or default refusal at the prompt.
44- errForceStopDeclined = errors.New("vev: force stop declined; daemon left running")
45- // errForceStopTimeout reports that the daemon never released lifecycle
46- // ownership within the bounded escalation policy.
47- errForceStopTimeout = errors.New("daemon did not release lifecycle ownership")
48-)
49-
50-// daemonProcess is a verified, same-user process candidate.
51-type daemonProcess struct {
52- PID int
53- Command string // display only; never used to infer ownership
54- // Start is an opaque OS-provided process start marker. It distinguishes
55- // incarnations so a reused PID is never signalled.
56- Start string
57-}
58-
59-// daemonProcessFinder locates the process holding lifecycle ownership. It must
60-// prove ownership from the process's open file descriptors, not from its name.
61-type daemonProcessFinder interface {
62- // Find returns the same-user process that holds lifecycle.lock open and
63- // runs as a vev daemon. ok is false when no such process exists.
64- Find(ctx context.Context, runtimeDir string) (daemonProcess, bool, error)
65-}
66-
67-// hasDaemonArgument reports whether args contain the exact daemon flag. It
68-// complements, and never replaces, the verified lock-holder proof.
69-func hasDaemonArgument(args []string) bool {
70- for _, arg := range args {
71- if arg == "--daemon" {
72- return true
73- }
74- }
75- return false
76-}
77-
78-// forceStopHooks carries every side effect so policy tests never signal a real
79-// process or read the real terminal.
80-type forceStopHooks struct {
81- probe lifecycleProbe
82- finder daemonProcessFinder
83- signal func(pid int, sig syscall.Signal) error
84- now func() time.Time
85- sleep func(ctx context.Context, d time.Duration) error
86- stdin io.Reader
87- stdout io.Writer
88-}
89-
90-func defaultForceStopHooks() forceStopHooks {
91- return forceStopHooks{
92- probe: daemonLifecycleProbe,
93- finder: platformDaemonProcessFinder{},
94- signal: syscall.Kill,
95- now: time.Now,
96- sleep: forceStopSleep,
97- stdin: os.Stdin,
98- stdout: os.Stdout,
99- }
100-}
101-
102-func forceStopSleep(ctx context.Context, d time.Duration) error {
103- timer := time.NewTimer(d)
104- defer timer.Stop()
105- select {
106- case <-ctx.Done():
107- return ctx.Err()
108- case <-timer.C:
109- return nil
110- }
111-}
112-
113-// forceStopDaemon returns nil once the daemon was stopped through the
114-// interactive fallback and its stale runtime artifacts were removed. It returns
115-// errForceStopNotApplicable without any side effect when the fallback does not
116-// apply, and errForceStopDeclined when the operator refused.
117-func forceStopDaemon(ctx context.Context, runtimeDir string, hooks forceStopHooks) (retErr error) {
118- if err := ctx.Err(); err != nil {
119- return err
120- }
121- // Ownership must already be busy: a free lock means there is no daemon to
122- // force-stop and the caller's normal path applies.
123- owner, err := hooks.probe.TryAcquire(runtimeDir)
124- if err == nil {
125- return errors.Join(errForceStopNotApplicable, owner.Release())
126- }
127- if !errors.Is(err, lifecycle.ErrBusy) {
128- return fmt.Errorf("vev: probing daemon ownership: %w", err)
129- }
130-
131- candidate, ok, err := hooks.finder.Find(ctx, runtimeDir)
132- if err != nil {
133- return fmt.Errorf("vev: identifying daemon process: %w", err)
134- }
135- if !ok {
136- return errForceStopNotApplicable
137- }
138-
139- confirmed, err := hooks.confirm(fmt.Sprintf(
140- "vev: daemon process %d (%s) still owns %s after the typed kill request failed.\n"+
141- "vev: it is unreachable through the typed control endpoint. Send SIGTERM to process %d?",
142- candidate.PID, candidate.Command, lifecycle.Path(runtimeDir), candidate.PID))
143- if err != nil {
144- return err
145- }
146- if !confirmed {
147- return errForceStopDeclined
148- }
149-
150- // The prompt is interactive and may stay open arbitrarily long, so the
151- // confirmed incarnation is re-verified before SIGTERM exactly as it is
152- // before SIGKILL: a PID reused while the operator was answering is never
153- // signalled. If the verified process vanished or changed, ownership may
154- // already be free because the daemon exited on its own; then only stale
155- // runtime artifacts are removed, without signalling anything.
156- current, found, findErr := hooks.finder.Find(ctx, runtimeDir)
157- if findErr != nil {
158- return fmt.Errorf("vev: re-identifying daemon process: %w", findErr)
159- }
160- if !found || current.PID != candidate.PID || current.Start != candidate.Start {
161- owner, acquireErr := hooks.probe.TryAcquire(runtimeDir)
162- if acquireErr != nil {
163- if !errors.Is(acquireErr, lifecycle.ErrBusy) {
164- return fmt.Errorf("vev: probing daemon ownership: %w", acquireErr)
165- }
166- return fmt.Errorf("vev: %w (process identity changed before SIGTERM)", errForceStopTimeout)
167- }
168- defer func() { retErr = errors.Join(retErr, owner.Release()) }()
169- if err := removeStaleRuntimeArtifacts(runtimeDir, hooks); err != nil {
170- return err
171- }
172- _, err = fmt.Fprintf(hooks.stdout, "vev: daemon already exited; removed stale runtime artifacts\n")
173- return err
174- }
175-
176- if err := hooks.signal(candidate.PID, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) {
177- return fmt.Errorf("vev: sending SIGTERM to process %d: %w", candidate.PID, err)
178- }
179-
180- owner, err = forceStopAwaitOwnership(ctx, runtimeDir, forceStopSIGTERMWait, hooks)
181- if err != nil {
182- if !errors.Is(err, errForceStopTimeout) {
183- return err
184- }
185- // Escalate exactly once, and only for the exact incarnation that was
186- // verified. A PID reused by another process is never signalled.
187- current, found, findErr := hooks.finder.Find(ctx, runtimeDir)
188- if findErr != nil {
189- return fmt.Errorf("vev: re-identifying daemon process: %w", findErr)
190- }
191- if !found || current.PID != candidate.PID || current.Start != candidate.Start {
192- return fmt.Errorf("vev: %w (process identity changed before SIGKILL)", errForceStopTimeout)
193- }
194- if err := hooks.signal(candidate.PID, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
195- return fmt.Errorf("vev: sending SIGKILL to process %d: %w", candidate.PID, err)
196- }
197- owner, err = forceStopAwaitOwnership(ctx, runtimeDir, forceStopSIGKILLWait, hooks)
198- if err != nil {
199- return fmt.Errorf("vev: %w after SIGKILL", err)
200- }
201- }
202- // Holding lifecycle ownership proves no daemon is starting or listening, so
203- // removing runtime artifacts cannot race a live daemon.
204- defer func() { retErr = errors.Join(retErr, owner.Release()) }()
205- if err := removeStaleRuntimeArtifacts(runtimeDir, hooks); err != nil {
206- return err
207- }
208- _, err = fmt.Fprintf(hooks.stdout, "vev: stopped daemon process %d and removed stale runtime artifacts\n", candidate.PID)
209- return err
210-}
211-
212-// forceStopAwaitOwnership polls until lifecycle ownership is free, then returns
213-// it so the caller can keep it through cleanup.
214-func forceStopAwaitOwnership(ctx context.Context, runtimeDir string, wait time.Duration, hooks forceStopHooks) (lifecycleOwnership, error) {
215- deadline := hooks.now().Add(wait)
216- for {
217- if err := ctx.Err(); err != nil {
218- return nil, err
219- }
220- owner, err := hooks.probe.TryAcquire(runtimeDir)
221- if err == nil {
222- return owner, nil
223- }
224- if !errors.Is(err, lifecycle.ErrBusy) {
225- return nil, err
226- }
227- if !hooks.now().Before(deadline) {
228- return nil, errForceStopTimeout
229- }
230- if err := hooks.sleep(ctx, forceStopPollInterval); err != nil {
231- return nil, err
232- }
233- }
234-}
235-
236-// removeStaleRuntimeArtifacts deletes only the runtime socket and an abandoned
237-// spawn lock. It never touches durable state: sessions.kv, snapshots,
238-// configuration, and logs live under the state directory and are not candidates
239-// here.
240-func removeStaleRuntimeArtifacts(runtimeDir string, hooks forceStopHooks) error {
241- var errs []error
242- if err := os.Remove(ipc.SocketPath(runtimeDir)); err != nil && !errors.Is(err, os.ErrNotExist) {
243- errs = append(errs, fmt.Errorf("vev: removing stale daemon socket: %w", err))
244- }
245- spawnPath := filepath.Join(runtimeDir, spawnLockName)
246- info, err := os.Stat(spawnPath)
247- switch {
248- case err == nil:
249- // A fresh spawn lock may belong to a concurrent client that is waiting
250- // for a daemon; only abandoned locks are removed.
251- if hooks.now().Sub(info.ModTime()) > staleLockAge {
252- if err := os.RemoveAll(spawnPath); err != nil {
253- errs = append(errs, fmt.Errorf("vev: removing stale spawn lock: %w", err))
254- }
255- }
256- case errors.Is(err, os.ErrNotExist):
257- default:
258- errs = append(errs, fmt.Errorf("vev: inspecting spawn lock: %w", err))
259- }
260- return errors.Join(errs...)
261-}
262-
263-// confirm prints a y/N prompt and returns true only for an explicit yes. Empty
264-// input, EOF, and every other reply default to refusal.
265-func (h forceStopHooks) confirm(question string) (bool, error) {
266- if _, err := fmt.Fprintf(h.stdout, "%s [y/N] ", question); err != nil {
267- return false, fmt.Errorf("vev: writing force-stop prompt: %w", err)
268- }
269- line, err := bufio.NewReader(h.stdin).ReadString('\n')
270- if err != nil && !errors.Is(err, io.EOF) {
271- return false, fmt.Errorf("vev: reading force-stop confirmation: %w", err)
272- }
273- switch strings.ToLower(strings.TrimSpace(line)) {
274- case "y", "yes":
275- return true, nil
276- default:
277- return false, nil
278- }
279-}
280-
281-// forceStopDaemonFn is the interactive last-resort hook used by runKill. Tests
282-// replace it to exercise the fallback wiring without a real daemon process.
283-var forceStopDaemonFn = func(ctx context.Context) error {
284- return forceStopDaemon(ctx, ipc.SocketDir(), defaultForceStopHooks())
285-}
286-
287-// forceStopDaemonFallback offers the interactive force-stop when the daemon
288-// cannot be reached through the typed protocol. Once a request may have been
289-// delivered, the caller preserves its explicit or outcome-unknown result rather
290-// than bypassing the protocol with a direct process signal. When no verified
291-// daemon process holds lifecycle ownership, the original failure is returned
292-// unchanged so the caller keeps its own diagnosis.
293-func forceStopDaemonFallback(ctx context.Context, cause error) error {
294- err := forceStopDaemonFn(ctx)
295- if errors.Is(err, errForceStopNotApplicable) {
296- return cause
297- }
298- return err
299-}
D internal/app/force_stop_darwin.go +0 -108
@@ -1,108 +0,0 @@
1-//go:build darwin
2-
3-package app
4-
5-import (
6- "context"
7- "errors"
8- "fmt"
9- "os"
10- "os/exec"
11- "strconv"
12- "strings"
13- "time"
14-
15- "github.com/bnema/vev/internal/adapters/lifecycle"
16-)
17-
18-// platformDaemonProcessFinder verifies ownership on macOS with lsof: the
19-// candidate must be a same-user process running a vev daemon with
20-// lifecycle.lock open. macOS has no /proc, and lsof ships with the base system;
21-// when it is unavailable the fallback refuses rather than guessing from a
22-// process name. An empty (legacy) lock file is irrelevant because ownership is
23-// proven by the open descriptor, not by file contents.
24-type platformDaemonProcessFinder struct{}
25-
26-// forceStopCommandTimeout bounds the lsof and ps subprocesses so a stuck
27-// process table cannot hang an interactive kill.
28-const forceStopCommandTimeout = 5 * time.Second
29-
30-func (platformDaemonProcessFinder) Find(ctx context.Context, runtimeDir string) (daemonProcess, bool, error) {
31- lockPath := lifecycle.Path(runtimeDir)
32- if _, err := os.Stat(lockPath); err != nil {
33- if errors.Is(err, os.ErrNotExist) {
34- return daemonProcess{}, false, nil
35- }
36- return daemonProcess{}, false, fmt.Errorf("inspecting lifecycle lock: %w", err)
37- }
38- lsof, err := lsofPath()
39- if err != nil {
40- return daemonProcess{}, false, err
41- }
42- lsofCtx, cancel := context.WithTimeout(ctx, forceStopCommandTimeout)
43- defer cancel()
44- out, err := exec.CommandContext(lsofCtx, lsof, "-a", "-u", strconv.Itoa(os.Geteuid()), "-t", "--", lockPath).Output()
45- if err != nil {
46- var exitErr *exec.ExitError
47- if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
48- // lsof exits 1 when no process matches.
49- return daemonProcess{}, false, nil
50- }
51- return daemonProcess{}, false, fmt.Errorf("listing holders of %s: %w", lockPath, err)
52- }
53- for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
54- pid, convErr := strconv.Atoi(strings.TrimSpace(line))
55- if convErr != nil || pid <= 0 {
56- continue
57- }
58- process, ok, infoErr := darwinProcessInfo(ctx, pid)
59- if infoErr != nil {
60- return daemonProcess{}, false, infoErr
61- }
62- if ok {
63- return process, true, nil
64- }
65- }
66- return daemonProcess{}, false, nil
67-}
68-
69-func lsofPath() (string, error) {
70- if path, err := exec.LookPath("lsof"); err == nil {
71- return path, nil
72- }
73- // GUI applications often omit /usr/sbin from PATH.
74- const systemLsof = "/usr/sbin/lsof"
75- if _, err := os.Stat(systemLsof); err == nil {
76- return systemLsof, nil
77- }
78- return "", errors.New("vev: locating lsof: not found; cannot verify daemon ownership")
79-}
80-
81-// darwinProcessInfo returns the verified daemon identity for pid, or ok=false
82-// when the process vanished or is not a vev daemon.
83-func darwinProcessInfo(ctx context.Context, pid int) (daemonProcess, bool, error) {
84- cmdCtx, cancel := context.WithTimeout(ctx, forceStopCommandTimeout)
85- defer cancel()
86- out, err := exec.CommandContext(cmdCtx, "ps", "-p", strconv.Itoa(pid), "-o", "uid=", "-o", "lstart=", "-o", "command=").Output()
87- if err != nil {
88- var exitErr *exec.ExitError
89- if errors.As(err, &exitErr) {
90- return daemonProcess{}, false, nil // process vanished between lsof and ps
91- }
92- return daemonProcess{}, false, fmt.Errorf("inspecting process %d: %w", pid, err)
93- }
94- // uid and the five lstart fields precede the command.
95- fields := strings.Fields(string(out))
96- if len(fields) < 7 {
97- return daemonProcess{}, false, nil
98- }
99- uid, convErr := strconv.Atoi(fields[0])
100- if convErr != nil || uid != os.Geteuid() {
101- return daemonProcess{}, false, nil
102- }
103- command := strings.Join(fields[6:], " ")
104- if !hasDaemonArgument(strings.Fields(command)) {
105- return daemonProcess{}, false, nil
106- }
107- return daemonProcess{PID: pid, Command: command, Start: strings.Join(fields[1:6], " ")}, true, nil
108-}
D internal/app/force_stop_darwin_test.go +0 -90
@@ -1,90 +0,0 @@
1-//go:build darwin
2-
3-package app
4-
5-import (
6- "context"
7- "os"
8- "os/exec"
9- "path/filepath"
10- "testing"
11- "time"
12-
13- "github.com/bnema/vev/internal/adapters/lifecycle"
14- "github.com/stretchr/testify/require"
15-)
16-
17-// TestDarwinForceStopFinderVerifiesDaemonHoldingEmptyLegacyLock proves
18-// ownership through lsof: the helper runs as `--daemon`, holds a legacy (empty)
19-// lifecycle.lock, and is identified without trusting the process name.
20-func TestDarwinForceStopFinderVerifiesDaemonHoldingEmptyLegacyLock(t *testing.T) {
21- if _, err := lsofPath(); err != nil {
22- t.Skip("lsof unavailable; the fallback refuses rather than guessing")
23- }
24- runtimeDir := filepath.Join(t.TempDir(), "runtime")
25- require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
26- childFile := filepath.Join(t.TempDir(), "daemon.pid")
27- t.Setenv(spawnTestChildFileEnv, childFile)
28- t.Setenv(spawnTestLockDirEnv, runtimeDir)
29-
30- cmd := exec.Command(os.Args[0], "--daemon")
31- cmd.Env = os.Environ()
32- require.NoError(t, cmd.Start())
33- t.Cleanup(func() {
34- _ = cmd.Process.Kill()
35- _, _ = cmd.Process.Wait()
36- })
37- daemonPID := waitForProcessRecord(t, childFile)
38-
39- finder := platformDaemonProcessFinder{}
40- var process daemonProcess
41- var ok bool
42- require.Eventually(t, func() bool {
43- var err error
44- process, ok, err = finder.Find(context.Background(), runtimeDir)
45- require.NoError(t, err)
46- return ok
47- }, 10*time.Second, 50*time.Millisecond, "finder did not identify the lock holder")
48-
49- require.Equal(t, daemonPID, process.PID)
50- require.Contains(t, process.Command, "--daemon")
51- require.NotEmpty(t, process.Start)
52-
53- lockInfo, err := os.Stat(lifecycle.Path(runtimeDir))
54- require.NoError(t, err)
55- require.EqualValues(t, 0, lockInfo.Size(), "the legacy lifecycle lock stays empty")
56-}
57-
58-// TestDarwinForceStopFinderIgnoresNonDaemonLockHolder proves a same-user process
59-// that merely holds lifecycle.lock is not a candidate: this test binary owns the
60-// lock but was not started as a vev daemon.
61-func TestDarwinForceStopFinderIgnoresNonDaemonLockHolder(t *testing.T) {
62- if _, err := lsofPath(); err != nil {
63- t.Skip("lsof unavailable; the fallback refuses rather than guessing")
64- }
65- runtimeDir := filepath.Join(t.TempDir(), "runtime")
66- owner, err := lifecycle.TryAcquire(runtimeDir)
67- require.NoError(t, err)
68- defer func() { require.NoError(t, owner.Release()) }()
69-
70- _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), runtimeDir)
71-
72- require.NoError(t, err)
73- require.False(t, ok, "ownership must not be inferred from a process name alone")
74-}
75-
76-func TestDarwinForceStopFinderIgnoresUnheldLockFile(t *testing.T) {
77- runtimeDir := filepath.Join(t.TempDir(), "runtime")
78- require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
79- require.NoError(t, os.WriteFile(lifecycle.Path(runtimeDir), nil, 0o600))
80-
81- _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), runtimeDir)
82- require.NoError(t, err)
83- require.False(t, ok)
84-}
85-
86-func TestDarwinForceStopFinderIgnoresMissingLockFile(t *testing.T) {
87- _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), filepath.Join(t.TempDir(), "runtime"))
88- require.NoError(t, err)
89- require.False(t, ok)
90-}
D internal/app/force_stop_linux.go +0 -134
@@ -1,134 +0,0 @@
1-//go:build linux
2-
3-package app
4-
5-import (
6- "context"
7- "errors"
8- "fmt"
9- "os"
10- "path/filepath"
11- "strconv"
12- "strings"
13- "syscall"
14-
15- "github.com/bnema/vev/internal/adapters/lifecycle"
16-)
17-
18-// platformDaemonProcessFinder verifies ownership through /proc: the candidate
19-// must be a same-user process whose open file descriptor table references
20-// lifecycle.lock. An empty (legacy) lock file is irrelevant because ownership
21-// is proven by the held descriptor, not by file contents.
22-type platformDaemonProcessFinder struct{}
23-
24-func (platformDaemonProcessFinder) Find(ctx context.Context, runtimeDir string) (daemonProcess, bool, error) {
25- lockInfo, err := os.Stat(lifecycle.Path(runtimeDir))
26- if err != nil {
27- if errors.Is(err, os.ErrNotExist) {
28- return daemonProcess{}, false, nil
29- }
30- return daemonProcess{}, false, fmt.Errorf("inspecting lifecycle lock: %w", err)
31- }
32- entries, err := os.ReadDir("/proc")
33- if err != nil {
34- return daemonProcess{}, false, fmt.Errorf("scanning process table: %w", err)
35- }
36- euid := os.Geteuid()
37- for _, entry := range entries {
38- if err := ctx.Err(); err != nil {
39- return daemonProcess{}, false, err
40- }
41- pid, convErr := strconv.Atoi(entry.Name())
42- if convErr != nil || pid <= 0 {
43- continue
44- }
45- uid, err := procUID(pid)
46- if err != nil || uid != euid {
47- continue
48- }
49- holds, err := procHoldsFile(pid, lockInfo)
50- if err != nil || !holds {
51- continue
52- }
53- args, err := procArgs(pid)
54- if err != nil || !hasDaemonArgument(args) {
55- continue
56- }
57- start, err := procStartMarker(pid)
58- if err != nil {
59- continue
60- }
61- return daemonProcess{PID: pid, Command: strings.Join(args, " "), Start: start}, true, nil
62- }
63- return daemonProcess{}, false, nil
64-}
65-
66-func procDir(pid int) string {
67- return filepath.Join("/proc", strconv.Itoa(pid))
68-}
69-
70-// procUID reports the owning user of a process directory. Under hidepid it may
71-// fail, in which case the candidate is skipped.
72-func procUID(pid int) (int, error) {
73- info, err := os.Stat(procDir(pid))
74- if err != nil {
75- return -1, err
76- }
77- stat, ok := info.Sys().(*syscall.Stat_t)
78- if !ok {
79- return -1, errors.New("unexpected stat type for process directory")
80- }
81- return int(stat.Uid), nil
82-}
83-
84-// procHoldsFile reports whether pid has an open descriptor referencing the same
85-// file as lockInfo, which is the ownership proof for lifecycle.lock.
86-func procHoldsFile(pid int, lockInfo os.FileInfo) (bool, error) {
87- fds, err := os.ReadDir(filepath.Join(procDir(pid), "fd"))
88- if err != nil {
89- return false, err
90- }
91- for _, fd := range fds {
92- info, err := os.Stat(filepath.Join(procDir(pid), "fd", fd.Name()))
93- if err != nil {
94- continue
95- }
96- if os.SameFile(info, lockInfo) {
97- return true, nil
98- }
99- }
100- return false, nil
101-}
102-
103-func procArgs(pid int) ([]string, error) {
104- raw, err := os.ReadFile(filepath.Join(procDir(pid), "cmdline"))
105- if err != nil {
106- return nil, err
107- }
108- args := strings.Split(string(raw), "\x00")
109- if len(args) > 0 && args[len(args)-1] == "" {
110- args = args[:len(args)-1]
111- }
112- return args, nil
113-}
114-
115-// procStartMarker returns the kernel start time of pid. It changes when a PID is
116-// reused by another process, whatever the new process is called.
117-func procStartMarker(pid int) (string, error) {
118- raw, err := os.ReadFile(filepath.Join(procDir(pid), "stat"))
119- if err != nil {
120- return "", err
121- }
122- end := strings.LastIndexByte(string(raw), ')')
123- if end < 0 {
124- return "", errors.New("malformed process stat")
125- }
126- fields := strings.Fields(string(raw[end+1:]))
127- // After the command name, field 22 of /proc/PID/stat (starttime) is at
128- // index 22-3 = 19 because the first listed field is the state.
129- const startTimeIndex = 19
130- if len(fields) <= startTimeIndex {
131- return "", errors.New("malformed process stat")
132- }
133- return fields[startTimeIndex], nil
134-}
D internal/app/force_stop_linux_test.go +0 -103
@@ -1,103 +0,0 @@
1-//go:build linux
2-
3-package app
4-
5-import (
6- "context"
7- "os"
8- "os/exec"
9- "path/filepath"
10- "testing"
11- "time"
12-
13- "github.com/bnema/vev/internal/adapters/lifecycle"
14- "github.com/stretchr/testify/require"
15-)
16-
17-// TestLinuxForceStopFinderVerifiesDaemonHoldingEmptyLegacyLock proves ownership
18-// through a real descriptor: the helper runs as `--daemon`, holds a legacy
19-// (empty) lifecycle.lock, and is identified without trusting the process name.
20-func TestLinuxForceStopFinderVerifiesDaemonHoldingEmptyLegacyLock(t *testing.T) {
21- runtimeDir := filepath.Join(t.TempDir(), "runtime")
22- require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
23- childFile := filepath.Join(t.TempDir(), "daemon.pid")
24- t.Setenv(spawnTestChildFileEnv, childFile)
25- t.Setenv(spawnTestLockDirEnv, runtimeDir)
26-
27- cmd := exec.Command(os.Args[0], "--daemon")
28- cmd.Env = os.Environ()
29- require.NoError(t, cmd.Start())
30- t.Cleanup(func() {
31- _ = cmd.Process.Kill()
32- _, _ = cmd.Process.Wait()
33- })
34- daemonPID := waitForProcessRecord(t, childFile)
35-
36- finder := platformDaemonProcessFinder{}
37- var process daemonProcess
38- var ok bool
39- require.Eventually(t, func() bool {
40- var err error
41- process, ok, err = finder.Find(context.Background(), runtimeDir)
42- require.NoError(t, err)
43- return ok
44- }, 5*time.Second, 10*time.Millisecond, "finder did not identify the lock holder")
45-
46- require.Equal(t, daemonPID, process.PID)
47- require.Contains(t, process.Command, "--daemon")
48- require.NotEmpty(t, process.Start)
49-
50- lockInfo, err := os.Stat(lifecycle.Path(runtimeDir))
51- require.NoError(t, err)
52- require.EqualValues(t, 0, lockInfo.Size(), "the legacy lifecycle lock stays empty")
53-}
54-
55-// TestLinuxForceStopFinderIgnoresNonDaemonLockHolder proves a same-user process
56-// that merely holds lifecycle.lock is not a candidate: this test binary owns the
57-// lock but was not started as a vev daemon.
58-func TestLinuxForceStopFinderIgnoresNonDaemonLockHolder(t *testing.T) {
59- runtimeDir := filepath.Join(t.TempDir(), "runtime")
60- owner, err := lifecycle.TryAcquire(runtimeDir)
61- require.NoError(t, err)
62- defer func() { require.NoError(t, owner.Release()) }()
63-
64- _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), runtimeDir)
65-
66- require.NoError(t, err)
67- require.False(t, ok, "ownership must not be inferred from a process name alone")
68-}
69-
70-func TestLinuxForceStopFinderIgnoresUnheldLockFile(t *testing.T) {
71- runtimeDir := filepath.Join(t.TempDir(), "runtime")
72- require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
73- require.NoError(t, os.WriteFile(lifecycle.Path(runtimeDir), nil, 0o600))
74-
75- _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), runtimeDir)
76- require.NoError(t, err)
77- require.False(t, ok)
78-}
79-
80-func TestLinuxForceStopFinderIgnoresMissingLockFile(t *testing.T) {
81- _, ok, err := platformDaemonProcessFinder{}.Find(context.Background(), filepath.Join(t.TempDir(), "runtime"))
82- require.NoError(t, err)
83- require.False(t, ok)
84-}
85-
86-// TestLinuxProcessStartMarkerIsStable pins the PID-reuse marker: the kernel
87-// start time of our own process is stable across reads and distinguishes
88-// incarnations.
89-func TestLinuxProcessStartMarkerIsStable(t *testing.T) {
90- first, err := procStartMarker(os.Getpid())
91- require.NoError(t, err)
92- require.NotEmpty(t, first)
93-
94- second, err := procStartMarker(os.Getpid())
95- require.NoError(t, err)
96- require.Equal(t, first, second)
97-
98- // Sanity: a different live process has a different marker or at least a
99- // parseable one.
100- other, err := procStartMarker(1)
101- require.NoError(t, err)
102- require.NotEmpty(t, other)
103-}
D internal/app/force_stop_test.go +0 -456
@@ -1,456 +0,0 @@
1-package app
2-
3-import (
4- "bytes"
5- "context"
6- "errors"
7- "io"
8- "os"
9- "path/filepath"
10- "strings"
11- "syscall"
12- "testing"
13- "time"
14-
15- "github.com/bnema/vev/internal/adapters/ipc"
16- "github.com/bnema/vev/internal/adapters/lifecycle"
17- "github.com/stretchr/testify/require"
18-)
19-
20-type funcLifecycleProbe struct {
21- tryAcquire func(string) (lifecycleOwnership, error)
22-}
23-
24-func (p funcLifecycleProbe) TryAcquire(dir string) (lifecycleOwnership, error) {
25- return p.tryAcquire(dir)
26-}
27-
28-type fakeDaemonFinder struct {
29- find func(ctx context.Context, runtimeDir string) (daemonProcess, bool, error)
30-}
31-
32-func (f fakeDaemonFinder) Find(ctx context.Context, runtimeDir string) (daemonProcess, bool, error) {
33- if f.find == nil {
34- return daemonProcess{}, false, nil
35- }
36- return f.find(ctx, runtimeDir)
37-}
38-
39-func freeLifecycleOwnership() lifecycleOwnership {
40- return fakeLifecycleOwnership{release: func() error { return nil }}
41-}
42-
43-// forceStopFixture drives forceStopDaemon without touching real processes, the
44-// real terminal, or the wall clock.
45-type forceStopFixture struct {
46- hooks forceStopHooks
47- signals []syscall.Signal
48- output bytes.Buffer
49- now time.Time
50- pid int
51-}
52-
53-func newForceStopFixture(t *testing.T, input string) *forceStopFixture {
54- t.Helper()
55- f := &forceStopFixture{now: time.Now(), pid: 4242}
56- f.hooks = forceStopHooks{
57- probe: funcLifecycleProbe{tryAcquire: func(string) (lifecycleOwnership, error) {
58- return nil, lifecycle.ErrBusy
59- }},
60- finder: fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) {
61- return daemonProcess{PID: f.pid, Command: "/proc/self/exe --daemon", Start: "start-1"}, true, nil
62- }},
63- signal: func(_ int, sig syscall.Signal) error {
64- f.signals = append(f.signals, sig)
65- return nil
66- },
67- now: func() time.Time { return f.now },
68- sleep: func(_ context.Context, d time.Duration) error { f.now = f.now.Add(d); return nil },
69- stdin: strings.NewReader(input),
70- stdout: &f.output,
71- }
72- return f
73-}
74-
75-// freeAfterSignals reports busy ownership until signals have been delivered.
76-func (f *forceStopFixture) freeAfterSignals(count int) {
77- f.hooks.probe = funcLifecycleProbe{tryAcquire: func(string) (lifecycleOwnership, error) {
78- if len(f.signals) >= count {
79- return freeLifecycleOwnership(), nil
80- }
81- return nil, lifecycle.ErrBusy
82- }}
83-}
84-
85-// forceStopRuntime builds a runtime directory with the artifacts a force-stop
86-// may remove, plus durable state it must never touch.
87-func forceStopRuntime(t *testing.T) (runtimeDir, stateDir string) {
88- t.Helper()
89- root := t.TempDir()
90- runtimeDir = filepath.Join(root, "runtime")
91- stateDir = filepath.Join(root, "state")
92- require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
93- require.NoError(t, os.MkdirAll(stateDir, 0o700))
94- require.NoError(t, os.WriteFile(ipc.SocketPath(runtimeDir), []byte("stale"), 0o600))
95- spawnLock := filepath.Join(runtimeDir, spawnLockName)
96- require.NoError(t, os.Mkdir(spawnLock, 0o700))
97- old := time.Now().Add(-time.Hour)
98- require.NoError(t, os.Chtimes(spawnLock, old, old))
99- require.NoError(t, os.WriteFile(filepath.Join(stateDir, "sessions.kv"), []byte("durable"), 0o600))
100- require.NoError(t, os.WriteFile(filepath.Join(stateDir, "config.toml"), []byte("config"), 0o600))
101- require.NoError(t, os.WriteFile(filepath.Join(stateDir, "vev.log"), []byte("log"), 0o600))
102- require.NoError(t, os.Mkdir(filepath.Join(stateDir, "snapshots"), 0o700))
103- return runtimeDir, stateDir
104-}
105-
106-func assertDurableStateIntact(t *testing.T, stateDir string) {
107- t.Helper()
108- for name, want := range map[string]string{
109- "sessions.kv": "durable",
110- "config.toml": "config",
111- "vev.log": "log",
112- } {
113- got, err := os.ReadFile(filepath.Join(stateDir, name))
114- require.NoError(t, err, name)
115- require.Equal(t, want, string(got), name)
116- }
117- info, err := os.Stat(filepath.Join(stateDir, "snapshots"))
118- require.NoError(t, err)
119- require.True(t, info.IsDir())
120-}
121-
122-func TestForceStopNotApplicableWhenOwnershipIsFree(t *testing.T) {
123- f := newForceStopFixture(t, "y\n")
124- f.hooks.probe = funcLifecycleProbe{tryAcquire: func(string) (lifecycleOwnership, error) {
125- return freeLifecycleOwnership(), nil
126- }}
127-
128- err := forceStopDaemon(context.Background(), t.TempDir(), f.hooks)
129-
130- require.ErrorIs(t, err, errForceStopNotApplicable)
131- require.Empty(t, f.signals)
132- require.NotContains(t, f.output.String(), "[y/N]", "no prompt may be offered without a busy lifecycle")
133-}
134-
135-func TestForceStopNotApplicableWithoutVerifiedProcess(t *testing.T) {
136- f := newForceStopFixture(t, "y\n")
137- f.hooks.finder = fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) {
138- return daemonProcess{}, false, nil
139- }}
140-
141- err := forceStopDaemon(context.Background(), t.TempDir(), f.hooks)
142-
143- require.ErrorIs(t, err, errForceStopNotApplicable)
144- require.Empty(t, f.signals)
145- require.NotContains(t, f.output.String(), "[y/N]")
146-}
147-
148-func TestForceStopPromptRequiresExplicitYes(t *testing.T) {
149- tests := []struct {
150- name string
151- input string
152- wantStopped bool
153- }{
154- {name: "empty input defaults to refusal", input: ""},
155- {name: "blank line defaults to refusal", input: "\n"},
156- {name: "n refuses", input: "n\n"},
157- {name: "no refuses", input: "no\n"},
158- {name: "yeah refuses", input: "yeah\n"},
159- {name: "numeric one refuses", input: "1\n"},
160- {name: "y accepts", input: "y\n", wantStopped: true},
161- {name: "yes accepts", input: "yes\n", wantStopped: true},
162- {name: "y without newline accepts", input: "y", wantStopped: true},
163- {name: "uppercase Y accepts", input: "Y\n", wantStopped: true},
164- {name: "mixed case Yes accepts", input: "Yes\n", wantStopped: true},
165- {name: "padded y accepts", input: " y \n", wantStopped: true},
166- }
167- for _, tt := range tests {
168- t.Run(tt.name, func(t *testing.T) {
169- f := newForceStopFixture(t, tt.input)
170- f.freeAfterSignals(1)
171- runtimeDir, _ := forceStopRuntime(t)
172-
173- err := forceStopDaemon(context.Background(), runtimeDir, f.hooks)
174-
175- if tt.wantStopped {
176- require.NoError(t, err)
177- require.Equal(t, []syscall.Signal{syscall.SIGTERM}, f.signals)
178- return
179- }
180- require.ErrorIs(t, err, errForceStopDeclined)
181- require.Empty(t, f.signals)
182- })
183- }
184-}
185-
186-func TestForceStopSendsSIGTERMAndRemovesStaleRuntimeArtifacts(t *testing.T) {
187- f := newForceStopFixture(t, "y\n")
188- f.freeAfterSignals(1)
189- runtimeDir, stateDir := forceStopRuntime(t)
190-
191- err := forceStopDaemon(context.Background(), runtimeDir, f.hooks)
192-
193- require.NoError(t, err)
194- require.Equal(t, []syscall.Signal{syscall.SIGTERM}, f.signals)
195- require.Contains(t, f.output.String(), "4242")
196- require.Contains(t, f.output.String(), "/proc/self/exe --daemon")
197- require.Contains(t, f.output.String(), lifecycle.Path(runtimeDir))
198- require.Contains(t, f.output.String(), "stopped daemon process 4242")
199- require.NoFileExists(t, ipc.SocketPath(runtimeDir))
200- require.NoDirExists(t, filepath.Join(runtimeDir, spawnLockName))
201- assertDurableStateIntact(t, stateDir)
202-}
203-
204-func TestForceStopKeepsFreshSpawnLock(t *testing.T) {
205- f := newForceStopFixture(t, "y\n")
206- f.freeAfterSignals(1)
207- runtimeDir, _ := forceStopRuntime(t)
208- fresh := filepath.Join(runtimeDir, spawnLockName)
209- require.NoError(t, os.Chtimes(fresh, f.now, f.now))
210-
211- require.NoError(t, forceStopDaemon(context.Background(), runtimeDir, f.hooks))
212-
213- require.NoFileExists(t, ipc.SocketPath(runtimeDir))
214- require.DirExists(t, fresh, "a concurrent spawner's fresh lock must be preserved")
215-}
216-
217-func TestForceStopEscalatesToSIGKILLOnceForTheSameProcess(t *testing.T) {
218- f := newForceStopFixture(t, "yes\n")
219- f.freeAfterSignals(2)
220- runtimeDir, stateDir := forceStopRuntime(t)
221- start := f.now
222-
223- require.NoError(t, forceStopDaemon(context.Background(), runtimeDir, f.hooks))
224-
225- require.Equal(t, []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL}, f.signals)
226- require.GreaterOrEqual(t, f.now.Sub(start), forceStopSIGTERMWait,
227- "SIGKILL must only follow the bounded SIGTERM wait")
228- require.NoFileExists(t, ipc.SocketPath(runtimeDir))
229- require.NoDirExists(t, filepath.Join(runtimeDir, spawnLockName))
230- assertDurableStateIntact(t, stateDir)
231-}
232-
233-func TestForceStopRefusesSIGKILLWhenProcessIdentityChanged(t *testing.T) {
234- f := newForceStopFixture(t, "y\n")
235- runtimeDir, stateDir := forceStopRuntime(t)
236- calls := 0
237- f.hooks.finder = fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) {
238- calls++
239- // The confirmed incarnation stays valid through the SIGTERM re-check and
240- // disappears only before the SIGKILL escalation.
241- if calls <= 2 {
242- return daemonProcess{PID: f.pid, Command: "vev --daemon", Start: "start-1"}, true, nil
243- }
244- // The verified incarnation is gone: the PID now belongs to another process.
245- return daemonProcess{PID: f.pid, Command: "unrelated", Start: "start-2"}, true, nil
246- }}
247-
248- err := forceStopDaemon(context.Background(), runtimeDir, f.hooks)
249-
250- require.ErrorIs(t, err, errForceStopTimeout)
251- require.Equal(t, []syscall.Signal{syscall.SIGTERM}, f.signals, "a reused PID must never receive SIGKILL")
252- require.FileExists(t, ipc.SocketPath(runtimeDir), "artifacts stay until ownership is released")
253- assertDurableStateIntact(t, stateDir)
254-}
255-
256-// promptChangeReader flips the verified incarnation as soon as the operator's
257-// answer is consumed, so the identity check that follows confirmation sees a
258-// PID that was reused while the prompt was open.
259-type promptChangeReader struct {
260- reader io.Reader
261- onRead func()
262-}
263-
264-func (r promptChangeReader) Read(p []byte) (int, error) {
265- n, err := r.reader.Read(p)
266- if n > 0 {
267- r.onRead()
268- }
269- return n, err
270-}
271-
272-func TestForceStopReverifiesIdentityAfterConfirmation(t *testing.T) {
273- tests := []struct {
274- name string
275- change func(f *forceStopFixture) (daemonProcess, bool, error)
276- }{
277- {
278- name: "pid reused by an unrelated process",
279- change: func(f *forceStopFixture) (daemonProcess, bool, error) {
280- return daemonProcess{PID: f.pid, Command: "unrelated", Start: "start-2"}, true, nil
281- },
282- },
283- {
284- name: "verified process vanished",
285- change: func(*forceStopFixture) (daemonProcess, bool, error) {
286- return daemonProcess{}, false, nil
287- },
288- },
289- }
290- for _, tt := range tests {
291- t.Run(tt.name, func(t *testing.T) {
292- f := newForceStopFixture(t, "y\n")
293- runtimeDir, stateDir := forceStopRuntime(t)
294- reverified := false
295- f.hooks.finder = fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) {
296- if reverified {
297- return tt.change(f)
298- }
299- return daemonProcess{PID: f.pid, Command: "/proc/self/exe --daemon", Start: "start-1"}, true, nil
300- }}
301- f.hooks.stdin = promptChangeReader{
302- reader: strings.NewReader("y\n"),
303- onRead: func() { reverified = true },
304- }
305-
306- err := forceStopDaemon(context.Background(), runtimeDir, f.hooks)
307-
308- require.ErrorIs(t, err, errForceStopTimeout)
309- require.Empty(t, f.signals, "the confirmed PID must never receive a signal once its identity changed")
310- require.FileExists(t, ipc.SocketPath(runtimeDir), "artifacts stay until ownership is released")
311- require.DirExists(t, filepath.Join(runtimeDir, spawnLockName))
312- assertDurableStateIntact(t, stateDir)
313- })
314- }
315-}
316-
317-// TestForceStopCleansArtifactsWhenProcessVanishesAndOwnershipFreed covers the
318-// daemon exiting while the operator answers the prompt: the verified process
319-// disappears and lifecycle ownership is free, so only stale runtime artifacts
320-// are removed and no signal is ever sent.
321-func TestForceStopCleansArtifactsWhenProcessVanishesAndOwnershipFreed(t *testing.T) {
322- f := newForceStopFixture(t, "y\n")
323- runtimeDir, stateDir := forceStopRuntime(t)
324- reverified := false
325- f.hooks.finder = fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) {
326- if reverified {
327- return daemonProcess{}, false, nil
328- }
329- return daemonProcess{PID: f.pid, Command: "/proc/self/exe --daemon", Start: "start-1"}, true, nil
330- }}
331- f.hooks.probe = funcLifecycleProbe{tryAcquire: func(string) (lifecycleOwnership, error) {
332- if reverified {
333- return freeLifecycleOwnership(), nil
334- }
335- return nil, lifecycle.ErrBusy
336- }}
337- f.hooks.stdin = promptChangeReader{
338- reader: strings.NewReader("y\n"),
339- onRead: func() { reverified = true },
340- }
341-
342- err := forceStopDaemon(context.Background(), runtimeDir, f.hooks)
343-
344- require.NoError(t, err)
345- require.Empty(t, f.signals, "an already-exited daemon must never be signalled")
346- require.NoFileExists(t, ipc.SocketPath(runtimeDir))
347- require.NoDirExists(t, filepath.Join(runtimeDir, spawnLockName))
348- assertDurableStateIntact(t, stateDir)
349-}
350-
351-// TestForceStopFailsWhenProcessChangesButOwnershipStaysBusy keeps the
352-// conservative path: a reused PID with ownership still held is never signalled
353-// and its runtime artifacts are left in place.
354-func TestForceStopFailsWhenProcessChangesButOwnershipStaysBusy(t *testing.T) {
355- f := newForceStopFixture(t, "y\n")
356- runtimeDir, stateDir := forceStopRuntime(t)
357- reverified := false
358- f.hooks.finder = fakeDaemonFinder{find: func(context.Context, string) (daemonProcess, bool, error) {
359- if reverified {
360- return daemonProcess{PID: f.pid, Command: "unrelated", Start: "start-2"}, true, nil
361- }
362- return daemonProcess{PID: f.pid, Command: "/proc/self/exe --daemon", Start: "start-1"}, true, nil
363- }}
364- f.hooks.stdin = promptChangeReader{
365- reader: strings.NewReader("y\n"),
366- onRead: func() { reverified = true },
367- }
368-
369- err := forceStopDaemon(context.Background(), runtimeDir, f.hooks)
370-
371- require.ErrorIs(t, err, errForceStopTimeout)
372- require.Empty(t, f.signals)
373- require.FileExists(t, ipc.SocketPath(runtimeDir), "artifacts stay while another process holds ownership")
374- assertDurableStateIntact(t, stateDir)
375-}
376-
377-func TestForceStopIsBoundedWhenOwnershipNeverTransfers(t *testing.T) {
378- f := newForceStopFixture(t, "y\n")
379- runtimeDir, _ := forceStopRuntime(t)
380- start := f.now
381-
382- err := forceStopDaemon(context.Background(), runtimeDir, f.hooks)
383-
384- require.ErrorIs(t, err, errForceStopTimeout)
385- require.Equal(t, []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL}, f.signals, "escalation never repeats")
386- require.GreaterOrEqual(t, f.now.Sub(start), forceStopSIGTERMWait+forceStopSIGKILLWait)
387- require.FileExists(t, ipc.SocketPath(runtimeDir))
388-}
389-
390-func TestForceStopDeclinedLeavesRuntimeArtifacts(t *testing.T) {
391- f := newForceStopFixture(t, "")
392- runtimeDir, stateDir := forceStopRuntime(t)
393-
394- err := forceStopDaemon(context.Background(), runtimeDir, f.hooks)
395-
396- require.ErrorIs(t, err, errForceStopDeclined)
397- require.FileExists(t, ipc.SocketPath(runtimeDir))
398- require.DirExists(t, filepath.Join(runtimeDir, spawnLockName))
399- assertDurableStateIntact(t, stateDir)
400-}
401-
402-func TestForceStopFallbackPreservesCauseWhenNotApplicable(t *testing.T) {
403- original := forceStopDaemonFn
404- t.Cleanup(func() { forceStopDaemonFn = original })
405- cause := errors.New("typed kill failed")
406- forceStopDaemonFn = func(context.Context) error { return errForceStopNotApplicable }
407-
408- require.ErrorIs(t, forceStopDaemonFallback(context.Background(), cause), cause)
409-
410- forceStopDaemonFn = func(context.Context) error { return nil }
411- require.NoError(t, forceStopDaemonFallback(context.Background(), cause))
412-
413- forceStopDaemonFn = func(context.Context) error { return errForceStopDeclined }
414- require.ErrorIs(t, forceStopDaemonFallback(context.Background(), cause), errForceStopDeclined)
415-}
416-
417-// TestRunKillOffersForceStopOnlyForDaemonScope drives the real runKill path with
418-// an unreachable daemon whose lifecycle ownership stays busy.
419-func TestRunKillOffersForceStopOnlyForDaemonScope(t *testing.T) {
420- originalBackoff := defaultBackoff
421- originalFallback := forceStopDaemonFn
422- t.Cleanup(func() {
423- defaultBackoff = originalBackoff
424- forceStopDaemonFn = originalFallback
425- })
426- defaultBackoff = backoffConfig{initial: time.Millisecond, max: time.Millisecond, total: 5 * time.Millisecond}
427-
428- t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
429- t.Setenv("XDG_STATE_HOME", t.TempDir())
430- owner, err := lifecycle.TryAcquire(ipc.SocketDir())
431- require.NoError(t, err)
432- defer func() { require.NoError(t, owner.Release()) }()
433-
434- t.Run("daemon scope offers the fallback", func(t *testing.T) {
435- called := 0
436- forceStopDaemonFn = func(context.Context) error { called++; return nil }
437- require.NoError(t, runKill(context.Background(), "", false, true))
438- require.Equal(t, 1, called)
439- })
440-
441- t.Run("daemon scope keeps the typed failure when no candidate exists", func(t *testing.T) {
442- called := 0
443- forceStopDaemonFn = func(context.Context) error { called++; return errForceStopNotApplicable }
444- err := runKill(context.Background(), "", false, true)
445- require.Equal(t, 1, called)
446- require.ErrorIs(t, err, ErrDaemonUnreachable)
447- })
448-
449- t.Run("all scope never offers the fallback", func(t *testing.T) {
450- called := 0
451- forceStopDaemonFn = func(context.Context) error { called++; return nil }
452- err := runKill(context.Background(), "", true, false)
453- require.Zero(t, called)
454- require.ErrorIs(t, err, ErrDaemonUnreachable)
455- })
456-}
D internal/app/kill_result_test.go +0 -429
@@ -1,429 +0,0 @@
1-package app
2-
3-// KillResult caller contract (explicit result slice). runKill and
4-// requestDaemonStop send one Kill with a unique nonzero RequestID and consume
5-// exactly one matching KillResult; a lost, uncorrelated, or wrong-typed reply is
6-// outcome-unknown and is never replayed, while a caller canceled before the
7-// send is a definite not-sent outcome. These tests drive the two helpers those
8-// callers share with a controllable in-memory client connection, so every case
9-// is bounded and uses no sleeps.
10-
11-import (
12- "context"
13- "errors"
14- "io"
15- "sync"
16- "sync/atomic"
17- "testing"
18- "time"
19-
20- "github.com/stretchr/testify/require"
21-
22- "github.com/bnema/vev/internal/adapters/ipc"
23- "github.com/bnema/vev/internal/adapters/sessionwire"
24- "github.com/bnema/vev/internal/ports"
25- "github.com/bnema/vev/internal/protocol"
26-)
27-
28-// fakeKillConnection is a scripted ports.ClientConnection. Sends are recorded
29-// and counted; ReceiveServer serves queued replies in order, then either a
30-// reply derived from the last sent Kill (replyForKill), a terminal error, or a
31-// block until Close.
32-type fakeKillConnection struct {
33- mu sync.Mutex
34- sends []protocol.ClientMessage
35- recv []protocol.ServerMessage
36- replyForKill func(protocol.Kill) protocol.ServerMessage
37- sendErr error
38- recvErr error
39- // block: once no reply is available, ReceiveServer parks until Close.
40- block bool
41- closed chan struct{}
42- once sync.Once
43- // recvEntered reports that a blocked ReceiveServer was actually entered.
44- recvEntered chan struct{}
45- enterOnce sync.Once
46-}
47-
48-func newFakeKillConnection() *fakeKillConnection {
49- return &fakeKillConnection{closed: make(chan struct{}), recvEntered: make(chan struct{})}
50-}
51-
52-func (c *fakeKillConnection) SendClient(message protocol.ClientMessage) error {
53- c.mu.Lock()
54- defer c.mu.Unlock()
55- if c.sendErr != nil {
56- return c.sendErr
57- }
58- c.sends = append(c.sends, message)
59- return nil
60-}
61-
62-func (c *fakeKillConnection) ReceiveServer() (protocol.ServerMessage, error) {
63- c.mu.Lock()
64- if len(c.recv) > 0 {
65- next := c.recv[0]
66- c.recv = c.recv[1:]
67- c.mu.Unlock()
68- return next, nil
69- }
70- // replyForKill derives a matching result from the exact request that was
71- // sent, so a test never has to predict the fresh random RequestID.
72- if c.replyForKill != nil && len(c.sends) > 0 {
73- if kill, ok := c.sends[len(c.sends)-1].(protocol.Kill); ok {
74- reply := c.replyForKill(kill)
75- c.mu.Unlock()
76- return reply, nil
77- }
78- }
79- block, recvErr := c.block, c.recvErr
80- c.mu.Unlock()
81- if !block {
82- return nil, recvErr
83- }
84- c.enterOnce.Do(func() { close(c.recvEntered) })
85- <-c.closed
86- return nil, io.ErrClosedPipe
87-}
88-
89-func (c *fakeKillConnection) Capabilities() protocol.ConnectionCapabilities {
90- return protocol.ConnectionCapabilities{}
91-}
92-
93-func (*fakeKillConnection) LinkState() ports.LinkState { return ports.LinkState(0) }
94-func (*fakeKillConnection) LinkEvents() <-chan ports.LinkEvent { return nil }
95-
96-func (c *fakeKillConnection) Close() error {
97- c.once.Do(func() { close(c.closed) })
98- return nil
99-}
100-
101-func (c *fakeKillConnection) sentCount() int {
102- c.mu.Lock()
103- defer c.mu.Unlock()
104- return len(c.sends)
105-}
106-
107-func (c *fakeKillConnection) sentKill(t *testing.T) protocol.Kill {
108- t.Helper()
109- c.mu.Lock()
110- defer c.mu.Unlock()
111- require.Len(t, c.sends, 1, "exactly one Kill must be sent (no replay)")
112- kill, ok := c.sends[0].(protocol.Kill)
113- require.True(t, ok, "sent message %T is not a Kill", c.sends[0])
114- return kill
115-}
116-
117-// sendAndAwait drives the shared send/receive pair once, as runKill and
118-// requestDaemonStop do.
119-func sendAndAwait(ctx context.Context, conn *fakeKillConnection, request protocol.Kill) error {
120- requestID, err := sendKillRequest(ctx, conn, request)
121- if err != nil {
122- return err
123- }
124- return receiveKillResult(ctx, conn, requestID)
125-}
126-
127-// matchingResult mirrors one Kill's RequestID into a successful result.
128-func matchingResult(outcome protocol.KillOutcome, code uint16, text string) func(protocol.Kill) protocol.ServerMessage {
129- return func(kill protocol.Kill) protocol.ServerMessage {
130- return protocol.KillResult{RequestID: kill.RequestID, Outcome: outcome, Code: code, Text: text}
131- }
132-}
133-
134-func TestKillResultMatchingSuccessConsumesOneCorrelatedResult(t *testing.T) {
135- conn := newFakeKillConnection()
136- conn.replyForKill = matchingResult(protocol.KillSucceeded, 0, "")
137-
138- require.NoError(t, sendAndAwait(context.Background(), conn, protocol.Kill{Name: "work", Scope: protocol.KillSession}))
139-
140- kill := conn.sentKill(t)
141- require.NotZero(t, kill.RequestID, "a Kill must carry a nonzero RequestID")
142- require.Equal(t, "work", kill.Name)
143- require.Equal(t, protocol.KillSession, kill.Scope)
144- require.Equal(t, 1, conn.sentCount())
145-}
146-
147-func TestKillResultFailedDefiniteIsNotUnknown(t *testing.T) {
148- tests := []struct {
149- name string
150- text string
151- }{
152- {name: "no such session", text: "no such session: work"},
153- {name: "participants changed", text: "session changed during the operation; try again"},
154- }
155- for _, tt := range tests {
156- t.Run(tt.name, func(t *testing.T) {
157- conn := newFakeKillConnection()
158- conn.replyForKill = matchingResult(protocol.KillFailed, protocol.ErrInternal, tt.text)
159-
160- err := sendAndAwait(context.Background(), conn, protocol.Kill{Name: "work", Scope: protocol.KillSession})
161- require.Error(t, err)
162- require.ErrorContains(t, err, tt.text)
163- require.NotErrorIs(t, err, errKillOutcomeUnknown, "a definite failure must not be reported as unknown")
164- require.Equal(t, 1, conn.sentCount(), "a definite failure must not be replayed")
165- })
166- }
167-}
168-
169-func TestKillResultUnknownOutcomeStaysUnknown(t *testing.T) {
170- conn := newFakeKillConnection()
171- conn.replyForKill = matchingResult(protocol.KillOutcomeUnknown, protocol.ErrServerShutdown, "daemon is shutting down")
172-
173- err := sendAndAwait(context.Background(), conn, protocol.Kill{Scope: protocol.KillAll})
174- require.ErrorIs(t, err, errKillOutcomeUnknown)
175- require.Equal(t, 1, conn.sentCount(), "an unknown outcome must never be replayed")
176-}
177-
178-func TestKillResultWrongTypeOrIDIsOutcomeUnknown(t *testing.T) {
179- tests := []struct {
180- name string
181- reply protocol.ServerMessage
182- }{
183- {name: "wrong type", reply: protocol.Pong{}},
184- {name: "wrong type error", reply: protocol.ErrorMsg{Code: protocol.ErrInternal, Text: "boom"}},
185- {name: "wrong id", reply: protocol.KillResult{RequestID: 99, Outcome: protocol.KillSucceeded}},
186- {name: "zero id", reply: protocol.KillResult{RequestID: 0, Outcome: protocol.KillSucceeded}},
187- }
188- for _, tt := range tests {
189- t.Run(tt.name, func(t *testing.T) {
190- conn := newFakeKillConnection()
191- conn.recv = []protocol.ServerMessage{tt.reply}
192-
193- err := sendAndAwait(context.Background(), conn, protocol.Kill{Scope: protocol.KillSession})
194- require.ErrorIs(t, err, errKillOutcomeUnknown)
195- require.Equal(t, 1, conn.sentCount(), "an uncorrelated reply must not trigger a replay")
196- })
197- }
198-}
199-
200-func TestKillResultClosedBeforeReplyIsOutcomeUnknown(t *testing.T) {
201- conn := newFakeKillConnection()
202- conn.recvErr = io.EOF
203-
204- err := sendAndAwait(context.Background(), conn, protocol.Kill{Scope: protocol.KillSession})
205- require.ErrorIs(t, err, errKillOutcomeUnknown)
206- require.Equal(t, 1, conn.sentCount(), "a close before the reply must never be read as success")
207-}
208-
209-// TestKillResultCancelAfterSendIsOutcomeUnknownWithoutReplay proves a caller
210-// canceled while awaiting the result closes the exact connection, reports an
211-// unknown outcome (never EOF-as-success), and never resends the request.
212-func TestKillResultCancelAfterSendIsOutcomeUnknownWithoutReplay(t *testing.T) {
213- conn := newFakeKillConnection()
214- conn.block = true
215-
216- ctx, cancel := context.WithCancel(context.Background())
217- defer cancel()
218-
219- done := make(chan error, 1)
220- go func() { done <- sendAndAwait(ctx, conn, protocol.Kill{Name: "work", Scope: protocol.KillSession}) }()
221-
222- // Wait until the receive is actually parked, then cancel. No sleep: the
223- // fake signals entry deterministically.
224- <-conn.recvEntered
225- cancel()
226-
227- require.ErrorIs(t, <-done, errKillOutcomeUnknown)
228- require.Equal(t, 1, conn.sentCount(), "a canceled kill must never be replayed")
229-
230- select {
231- case <-conn.closed:
232- default:
233- t.Fatal("cancellation after send must close the exact connection")
234- }
235-}
236-
237-// TestKillResultTimeoutAfterSendIsOutcomeUnknownWithoutReplay proves a caller
238-// whose result deadline expires while awaiting the reply closes the exact
239-// connection, reports an unknown outcome, and never resends the request.
240-func TestKillResultTimeoutAfterSendIsOutcomeUnknownWithoutReplay(t *testing.T) {
241- conn := newFakeKillConnection()
242- conn.block = true
243-
244- ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
245- defer cancel()
246-
247- done := make(chan error, 1)
248- go func() { done <- sendAndAwait(ctx, conn, protocol.Kill{Name: "work", Scope: protocol.KillSession}) }()
249-
250- <-conn.recvEntered
251- require.ErrorIs(t, <-done, errKillOutcomeUnknown)
252- require.Equal(t, 1, conn.sentCount(), "a timed-out kill must never be replayed")
253-
254- select {
255- case <-conn.closed:
256- default:
257- t.Fatal("a result timeout must close the exact connection")
258- }
259-}
260-
261-// TestKillResultCancelBeforeSendIsDefiniteNotSent proves a caller canceled
262-// before the request is sent reports a definite not-sent outcome and puts no
263-// bytes on the wire.
264-func TestKillResultCancelBeforeSendIsDefiniteNotSent(t *testing.T) {
265- conn := newFakeKillConnection()
266-
267- ctx, cancel := context.WithCancel(context.Background())
268- cancel()
269-
270- err := sendAndAwait(ctx, conn, protocol.Kill{Name: "work", Scope: protocol.KillSession})
271- require.ErrorIs(t, err, errKillNotSent)
272- require.NotErrorIs(t, err, errKillOutcomeUnknown, "a not-sent request has a definite outcome")
273- require.Zero(t, conn.sentCount(), "a canceled-before-send kill must not be sent")
274-}
275-
276-func TestKillRequestSendFailureIsReported(t *testing.T) {
277- conn := newFakeKillConnection()
278- conn.sendErr = errors.New("write failed")
279-
280- err := sendAndAwait(context.Background(), conn, protocol.Kill{Name: "work", Scope: protocol.KillSession})
281- require.ErrorContains(t, err, "write failed")
282- require.Zero(t, conn.sentCount())
283-}
284-
285-// TestKillRequestIDsAreUniqueAndNonzero proves each request allocates a fresh,
286-// nonzero correlation ID so a reply for one request can never match another.
287-func TestKillRequestIDsAreUniqueAndNonzero(t *testing.T) {
288- seen := make(map[uint64]struct{})
289- for range 64 {
290- conn := newFakeKillConnection()
291- conn.replyForKill = matchingResult(protocol.KillSucceeded, 0, "")
292- require.NoError(t, sendAndAwait(context.Background(), conn, protocol.Kill{Scope: protocol.KillAll}))
293- id := conn.sentKill(t).RequestID
294- require.NotZero(t, id)
295- _, duplicate := seen[id]
296- require.False(t, duplicate, "duplicate RequestID %d", id)
297- seen[id] = struct{}{}
298- }
299-}
300-
301-// countingLifecycleProbe records every lifecycle-acquire attempt and returns
302-// the configured owner or error, so a test can prove both whether the transfer
303-// wait was entered and how it resolved.
304-type countingLifecycleProbe struct {
305- attempts atomic.Int64
306- owner lifecycleOwnership
307- err error
308-}
309-
310-func (p *countingLifecycleProbe) TryAcquire(string) (lifecycleOwnership, error) {
311- p.attempts.Add(1)
312- return p.owner, p.err
313-}
314-
315-// startServingOneKill binds ipc.SocketDir() under a temp runtime root, points
316-// the lifecycle probe at a counting fake, and serves exactly one control
317-// connection in the background. reply==nil closes the connection without
318-// answering, which models a lost reply; otherwise the reply is correlated to the
319-// exact request read from the wire. The returned channel closes once the
320-// scripted server has finished.
321-func startServingOneKill(t *testing.T, probe *countingLifecycleProbe, reply func(protocol.Kill) protocol.ServerMessage) <-chan struct{} {
322- t.Helper()
323- t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
324- t.Setenv("XDG_STATE_HOME", t.TempDir())
325-
326- // Bound the transfer wait so a regression that wrongly waits fails fast
327- // instead of burning the production retry budget.
328- originalBackoff := defaultBackoff
329- t.Cleanup(func() { defaultBackoff = originalBackoff })
330- defaultBackoff = backoffConfig{initial: time.Millisecond, max: time.Millisecond, total: 5 * time.Millisecond}
331-
332- originalProbe := daemonLifecycleProbe
333- t.Cleanup(func() { daemonLifecycleProbe = originalProbe })
334- daemonLifecycleProbe = probe
335-
336- listener, err := ipc.Listen(ipc.SocketDir())
337- require.NoError(t, err)
338- t.Cleanup(func() { _ = listener.Close() })
339-
340- done := make(chan struct{})
341- go func() {
342- defer close(done)
343- raw, err := listener.Accept()
344- if err != nil {
345- return
346- }
347- defer func() { _ = raw.Close() }()
348- connection := sessionwire.NewServerConnection(raw)
349- message, err := connection.ReceiveClient()
350- if err != nil {
351- return
352- }
353- kill, ok := message.(protocol.Kill)
354- if !ok || reply == nil {
355- return
356- }
357- _ = connection.SendServer(reply(kill))
358- }()
359- return done
360-}
361-
362-func awaitServingOneKill(t *testing.T, done <-chan struct{}) {
363- t.Helper()
364- select {
365- case <-done:
366- case <-time.After(2 * time.Second):
367- t.Fatal("the scripted control server did not finish")
368- }
369-}
370-
371-// TestDaemonStopLostReplyConfirmedByLifecycleTransfer proves a daemon-stop whose
372-// explicit result is lost is still reported as success once the caller directly
373-// acquires lifecycle ownership: the authoritative transfer confirms the accepted
374-// shutdown completed.
375-func TestDaemonStopLostReplyConfirmedByLifecycleTransfer(t *testing.T) {
376- probe := &countingLifecycleProbe{owner: fakeLifecycleOwnership{release: func() error { return nil }}}
377- done := startServingOneKill(t, probe, nil)
378-
379- require.NoError(t, requestDaemonStop(context.Background()))
380- awaitServingOneKill(t, done)
381- require.EqualValues(t, 1, probe.attempts.Load(), "a lost reply must be resolved by exactly one ownership probe")
382-}
383-
384-// TestDaemonStopLostReplyAndTransferFailurePreservesUnknownOutcome proves a
385-// daemon-stop whose result is lost AND whose lifecycle confirmation fails keeps
386-// errors.Is(err, errKillOutcomeUnknown) and carries both diagnostics, so the
387-// caller never reads a lost reply as a definite success or a definite failure.
388-func TestDaemonStopLostReplyAndTransferFailurePreservesUnknownOutcome(t *testing.T) {
389- probe := &countingLifecycleProbe{err: errors.New("lifecycle still busy")}
390- done := startServingOneKill(t, probe, nil)
391-
392- err := requestDaemonStop(context.Background())
393- require.ErrorIs(t, err, errKillOutcomeUnknown)
394- require.ErrorContains(t, err, "awaiting explicit result", "the lost-reply diagnostic must survive")
395- require.ErrorContains(t, err, "lifecycle confirmation also failed")
396- awaitServingOneKill(t, done)
397-}
398-
399-// TestDaemonStopDefiniteFailureReturnsWithoutLifecycleWait proves a definite
400-// KillFailed daemon-stop reply is returned immediately and never enters the
401-// lifecycle-transfer wait, so a rejected stop cannot be retried as if the
402-// outcome were still unknown.
403-func TestDaemonStopDefiniteFailureReturnsWithoutLifecycleWait(t *testing.T) {
404- probe := &countingLifecycleProbe{err: errors.New("lifecycle must not be probed")}
405- done := startServingOneKill(t, probe, func(kill protocol.Kill) protocol.ServerMessage {
406- return protocol.KillResult{RequestID: kill.RequestID, Outcome: protocol.KillFailed, Code: protocol.ErrInternal, Text: "daemon refused stop"}
407- })
408-
409- err := requestDaemonStop(context.Background())
410- require.Error(t, err)
411- require.ErrorContains(t, err, "daemon refused stop")
412- require.NotErrorIs(t, err, errKillOutcomeUnknown, "a definite failure must not be reported as unknown")
413- require.Zero(t, probe.attempts.Load(), "a definite failure must return without waiting for ownership transfer")
414- awaitServingOneKill(t, done)
415-}
416-
417-// TestDaemonStopUnknownResultKeepsUnknownWhenOwnershipReleaseFails pins the
418-// ownership-release branch: even when the probe grants ownership, a release
419-// failure keeps the lost-reply diagnostic instead of discarding it.
420-func TestDaemonStopUnknownResultKeepsUnknownWhenOwnershipReleaseFails(t *testing.T) {
421- probe := &countingLifecycleProbe{owner: fakeLifecycleOwnership{release: func() error { return errors.New("unlock failed") }}}
422- done := startServingOneKill(t, probe, nil)
423-
424- err := requestDaemonStop(context.Background())
425- require.ErrorIs(t, err, errKillOutcomeUnknown)
426- require.ErrorContains(t, err, "confirmed ownership release failed")
427- require.ErrorContains(t, err, "unlock failed")
428- awaitServingOneKill(t, done)
429-}
D internal/app/launch_allowlist.go +0 -158
@@ -1,158 +0,0 @@
1-package app
2-
3-import (
4- "context"
5- "errors"
6- "os"
7- "strings"
8-
9- "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/internal/protocol"
12- "github.com/bnema/vev/internal/protocol/catalogue"
13-)
14-
15-func remoteLaunchAllowlistFromEnv() (map[string]struct{}, bool, error) {
16- raw, configured := os.LookupEnv(launchAllowedRemoteEndpointsEnv)
17- if !configured {
18- return nil, false, nil
19- }
20- allowed := make(map[string]struct{})
21- for _, target := range strings.Split(raw, "\n") {
22- if target == "" {
23- continue
24- }
25- if err := domain.ValidateRemoteHostTarget(target); err != nil {
26- return nil, true, errors.New("vev: invalid configured remote endpoint allowlist")
27- }
28- allowed[target] = struct{}{}
29- }
30- return allowed, true, nil
31-}
32-
33-func allowlistedRemoteEndpoint(allowed map[string]struct{}, target string) bool {
34- _, ok := allowed[target]
35- return ok
36-}
37-
38-type allowlistedRemoteHostStore struct {
39- delegate ports.RemoteHostStore
40- allowed map[string]struct{}
41-}
42-
43-func (s allowlistedRemoteHostStore) Hosts() (pinned, learned []domain.RemoteRegistration, err error) {
44- pinned, learned, err = s.delegate.Hosts()
45- return filterRemoteRegistrations(pinned, s.allowed), filterRemoteRegistrations(learned, s.allowed), err
46-}
47-
48-func (s allowlistedRemoteHostStore) AddPinned(target string) error {
49- if !allowlistedRemoteEndpoint(s.allowed, target) {
50- return endpointNotConfiguredError()
51- }
52- return s.delegate.AddPinned(target)
53-}
54-
55-func (s allowlistedRemoteHostStore) RemovePinned(target string) error {
56- if !allowlistedRemoteEndpoint(s.allowed, target) {
57- return endpointNotConfiguredError()
58- }
59- return s.delegate.RemovePinned(target)
60-}
61-
62-func (s allowlistedRemoteHostStore) Remember(target string) error {
63- if !allowlistedRemoteEndpoint(s.allowed, target) {
64- return endpointNotConfiguredError()
65- }
66- return s.delegate.Remember(target)
67-}
68-
69-func (s allowlistedRemoteHostStore) Forget(target string) error {
70- if !allowlistedRemoteEndpoint(s.allowed, target) {
71- return endpointNotConfiguredError()
72- }
73- return s.delegate.Forget(target)
74-}
75-
76-func (s allowlistedRemoteHostStore) Remove(target string) (bool, error) {
77- if !allowlistedRemoteEndpoint(s.allowed, target) {
78- return false, endpointNotConfiguredError()
79- }
80- return s.delegate.Remove(target)
81-}
82-
83-type allowlistedRemoteCatalogClient struct {
84- delegate ports.RemoteCatalogClient
85- allowed map[string]struct{}
86-}
87-
88-func (c allowlistedRemoteCatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) {
89- if !allowlistedRemoteEndpoint(c.allowed, target) {
90- return catalogue.RemoteCatalog{}, endpointNotConfiguredError()
91- }
92- return c.delegate.List(ctx, target)
93-}
94-
95-type allowlistedRemotePreviewClient struct {
96- delegate ports.RemotePreviewClient
97- allowed map[string]struct{}
98-}
99-
100-func (c allowlistedRemotePreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (protocol.RemotePreview, error) {
101- if !allowlistedRemoteEndpoint(c.allowed, target.Endpoint) {
102- return protocol.RemotePreview{}, endpointNotConfiguredError()
103- }
104- return c.delegate.Preview(ctx, target, width, height)
105-}
106-
107-type allowlistedRemoteCatalogCache struct {
108- delegate ports.RemoteCatalogCache
109- allowed map[string]struct{}
110-}
111-
112-func (c allowlistedRemoteCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
113- entries, err := c.delegate.Load()
114- if err != nil {
115- return nil, err
116- }
117- filtered := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries))
118- for _, entry := range entries {
119- if allowlistedRemoteEndpoint(c.allowed, entry.Host) {
120- filtered = append(filtered, entry)
121- }
122- }
123- return filtered, nil
124-}
125-
126-func (c allowlistedRemoteCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
127- filtered := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries))
128- for _, entry := range entries {
129- if allowlistedRemoteEndpoint(c.allowed, entry.Host) {
130- filtered = append(filtered, entry)
131- }
132- }
133- return c.delegate.Store(filtered)
134-}
135-
136-func filterRemoteTargets(targets []string, allowed map[string]struct{}) []string {
137- filtered := make([]string, 0, len(targets))
138- for _, target := range targets {
139- if allowlistedRemoteEndpoint(allowed, target) {
140- filtered = append(filtered, target)
141- }
142- }
143- return filtered
144-}
145-
146-func filterRemoteRegistrations(registrations []domain.RemoteRegistration, allowed map[string]struct{}) []domain.RemoteRegistration {
147- filtered := make([]domain.RemoteRegistration, 0, len(registrations))
148- for _, record := range registrations {
149- if allowlistedRemoteEndpoint(allowed, record.Endpoint) {
150- filtered = append(filtered, record)
151- }
152- }
153- return filtered
154-}
155-
156-func endpointNotConfiguredError() error {
157- return &ports.UIError{Code: ports.UIErrEndpointNotConfigured}
158-}
D internal/app/missing_session_preflight_test.go +0 -446
@@ -1,446 +0,0 @@
1-package app
2-
3-import (
4- "context"
5- "errors"
6- "io"
7- "os"
8- "strings"
9- "syscall"
10- "testing"
11- "time"
12-
13- "github.com/stretchr/testify/mock"
14- "github.com/stretchr/testify/require"
15-
16- "github.com/bnema/vev/internal/adapters/term"
17- "github.com/bnema/vev/internal/ports"
18- portsmocks "github.com/bnema/vev/internal/ports/mocks"
19- "github.com/bnema/vev/internal/protocol"
20- "github.com/bnema/vev/internal/protocol/wire"
21- wiremocks "github.com/bnema/vev/internal/protocol/wire/mocks"
22- "github.com/bnema/vev/internal/usecase/client"
23- "github.com/bnema/vev/pkg/rawterm"
24-)
25-
26-// sessionListDialer serves a canned session listing over generated mocks. The
27-// typed client connection runs the preamble lazily, so the transport answers
28-// the preamble request before the listing exchange.
29-func sessionListDialer(t *testing.T, sessions []protocol.SessionInfo) func() wire.Dialer {
30- t.Helper()
31- transport := wiremocks.NewMockTransport(t)
32- transport.EXPECT().Send(mock.Anything).Return(nil).Twice()
33- transport.EXPECT().Recv().Return(mustPreambleResponse(), nil).Once()
34- transport.EXPECT().Recv().Return(mustServerEnvelope(protocol.Sessions{Sessions: sessions}), nil).Once()
35- transport.EXPECT().Close().Return(nil)
36- dialer := wiremocks.NewMockDialer(t)
37- dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
38- return func() wire.Dialer { return dialer }
39-}
40-
41-// failingSessionListDialer fails the listing dial so the preflight falls
42-// back to a plain attach.
43-func failingSessionListDialer(t *testing.T, dialErr error) func() wire.Dialer {
44- t.Helper()
45- dialer := wiremocks.NewMockDialer(t)
46- dialer.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Maybe()
47- return func() wire.Dialer { return dialer }
48-}
49-
50-// terminalStub serves canned prompt I/O through a generated terminal mock.
51-func terminalStub(t *testing.T, in io.Reader, out io.Writer) func() ports.Terminal {
52- t.Helper()
53- terminal := portsmocks.NewMockTerminal(t)
54- terminal.EXPECT().In().Return(in).Maybe()
55- terminal.EXPECT().Out().Return(out).Maybe()
56- terminal.EXPECT().Flush().Return(nil).Maybe()
57- return func() ports.Terminal { return terminal }
58-}
59-
60-// interactiveProbe injects the console decision, standing in for the
61-// production probe so the prompt matrix runs without owning a real TTY.
62-func interactiveProbe(interactive bool) func(ports.Terminal) bool {
63- return func(ports.Terminal) bool { return interactive }
64-}
65-
66-func TestRunAttachWithDepsMissingSessionCreatePrompt(t *testing.T) {
67- tests := []struct {
68- name string
69- sessions []protocol.SessionInfo
70- answer string
71- notInteractive bool
72- wantIntent uint8
73- wantPromptPart string
74- wantNoPrompt bool
75- }{
76- {name: "missing confirm creates", sessions: nil, answer: "y\n", wantIntent: protocol.IntentNew, wantPromptPart: `vev: session "scratch" doesn't exist, want to create and attach to it? [y/N]`},
77- {name: "missing decline attaches", sessions: nil, answer: "n\n", wantIntent: protocol.IntentAttach, wantPromptPart: "scratch"},
78- {name: "missing empty answer attaches", sessions: nil, answer: "\n", wantIntent: protocol.IntentAttach},
79- {name: "missing unknown answer attaches", sessions: nil, answer: "later\n", wantIntent: protocol.IntentAttach, wantPromptPart: "[y/N]"},
80- {name: "present attaches without prompt", sessions: []protocol.SessionInfo{{Name: "scratch"}}, answer: "y\n", wantIntent: protocol.IntentAttach, wantNoPrompt: true},
81- {name: "non-interactive console attaches without prompt", sessions: nil, answer: "y\n", notInteractive: true, wantIntent: protocol.IntentAttach, wantNoPrompt: true},
82- }
83- for _, tt := range tests {
84- t.Run(tt.name, func(t *testing.T) {
85- var intents []uint8
86- var promptOut strings.Builder
87- err := runAttachWithDeps(context.Background(), protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{
88- localDialer: sessionListDialer(t, tt.sessions),
89- terminal: terminalStub(t, strings.NewReader(tt.answer), &promptOut),
90- interactiveConsole: interactiveProbe(!tt.notInteractive),
91- runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error {
92- intents = append(intents, request.Intent)
93- return nil
94- },
95- })
96- require.NoError(t, err)
97- require.Equal(t, []uint8{tt.wantIntent}, intents, "preflight must select the attach intent before any client attempt")
98- if tt.wantPromptPart != "" {
99- require.Contains(t, promptOut.String(), tt.wantPromptPart)
100- }
101- if tt.wantNoPrompt {
102- require.Empty(t, promptOut.String())
103- }
104- })
105- }
106-}
107-
108-// TestTerminalIsInteractive covers the production probe on both sides of the
109-// console check: a real terminal file and the streams that are not consoles.
110-func TestTerminalIsInteractive(t *testing.T) {
111- require.False(t, terminalIsInteractive(nil), "a missing terminal cannot prompt")
112- pty := openPtySlave(t)
113- require.True(t, terminalIsInteractive(term.NewWithFiles(pty, pty)), "a real terminal file is a console")
114-
115- console, writer, err := os.Pipe()
116- require.NoError(t, err)
117- t.Cleanup(func() {
118- _ = console.Close()
119- _ = writer.Close()
120- })
121-
122- for _, tt := range []struct {
123- name string
124- in io.Reader
125- }{
126- {name: "detached reader", in: strings.NewReader("y\n")},
127- {name: "non-terminal file", in: console},
128- } {
129- t.Run(tt.name, func(t *testing.T) {
130- require.False(t, terminalIsInteractive(terminalStub(t, tt.in, io.Discard)()))
131- })
132- }
133-}
134-
135-// openPtySlave returns the slave side of a real PTY pair: the only way to
136-// hand the probe an *os.File the kernel reports as a terminal.
137-func openPtySlave(t *testing.T) *os.File {
138- t.Helper()
139- master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY|syscall.O_CLOEXEC, 0)
140- if err != nil {
141- t.Skipf("open /dev/ptmx: %v", err)
142- }
143- t.Cleanup(func() { _ = master.Close() })
144- slave, err := rawterm.PreparePty(int(master.Fd()))
145- require.NoError(t, err, "prepare pty")
146- t.Cleanup(func() { _ = slave.Close() })
147- return slave
148-}
149-
150-// TestRunAttachWithDepsMissingSessionPromptFlushesQuestion drives the real
151-// terminal adapter, whose output is buffered: the preflight has to flush the
152-// question before it blocks on the answer, or the user waits in front of a
153-// blank console and the question only shows up after the daemon replies. The
154-// observed wrapper used by `--ui-observe` embeds the port, so it has to keep
155-// flushing end to end.
156-func TestRunAttachWithDepsMissingSessionPromptFlushesQuestion(t *testing.T) {
157- for _, tt := range []struct {
158- name string
159- wrap func(ports.Terminal) ports.Terminal
160- }{
161- {name: "direct terminal", wrap: func(terminal ports.Terminal) ports.Terminal { return terminal }},
162- {name: "observed terminal", wrap: func(terminal ports.Terminal) ports.Terminal {
163- return observedTerminal{Terminal: terminal}
164- }},
165- } {
166- t.Run(tt.name, func(t *testing.T) {
167- consoleIn, answers, err := os.Pipe()
168- require.NoError(t, err)
169- questions, consoleOut, err := os.Pipe()
170- require.NoError(t, err)
171- t.Cleanup(func() {
172- _ = consoleIn.Close()
173- _ = answers.Close()
174- _ = questions.Close()
175- _ = consoleOut.Close()
176- })
177-
178- terminal := tt.wrap(term.NewWithFiles(consoleIn, consoleOut))
179- var intents []uint8
180- done := make(chan error, 1)
181- go func() {
182- done <- runAttachWithDeps(context.Background(), protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{
183- localDialer: sessionListDialer(t, nil),
184- terminal: func() ports.Terminal { return terminal },
185- interactiveConsole: interactiveProbe(true),
186- runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error {
187- intents = append(intents, request.Intent)
188- return nil
189- },
190- })
191- }()
192-
193- question := readPrompt(t, questions)
194- require.Contains(t, question, "want to create and attach to it? [y/N] ", "the question must reach the console before the preflight reads an answer")
195-
196- _, err = answers.WriteString("y\n")
197- require.NoError(t, err)
198- select {
199- case err := <-done:
200- require.NoError(t, err)
201- case <-time.After(5 * time.Second):
202- t.Fatal("the preflight did not release after the answer")
203- }
204- require.Equal(t, []uint8{protocol.IntentNew}, intents)
205- })
206- }
207-}
208-
209-// readPrompt returns the console output that ends the create question,
210-// failing the test instead of hanging when nothing arrives.
211-func readPrompt(t *testing.T, console *os.File) string {
212- t.Helper()
213- require.NoError(t, console.SetReadDeadline(time.Now().Add(5*time.Second)))
214- var output []byte
215- frame := make([]byte, 64)
216- for {
217- read, err := console.Read(frame)
218- output = append(output, frame[:read]...)
219- if strings.Contains(string(output), "[y/N] ") {
220- return string(output)
221- }
222- if err != nil {
223- t.Fatalf("preflight prompt never reached the console (read %q: %v)", output, err)
224- }
225- }
226-}
227-
228-// TestRunAttachWithDepsMissingSessionPromptFailureAttaches covers a console
229-// that cannot show the question: the preflight must keep the plain attach
230-// path instead of consuming an answer nobody was asked for. The input blocks
231-// forever, so a prompt that reads it would only return on context expiry.
232-func TestRunAttachWithDepsMissingSessionPromptFailureAttaches(t *testing.T) {
233- closedOut, err := os.CreateTemp(t.TempDir(), "vev-console")
234- require.NoError(t, err)
235- require.NoError(t, closedOut.Close())
236-
237- for _, tt := range []struct {
238- name string
239- out io.Writer
240- flushErr error
241- }{
242- {name: "write fails", out: closedOut},
243- {name: "flush fails", out: io.Discard, flushErr: errors.New("console gone")},
244- } {
245- t.Run(tt.name, func(t *testing.T) {
246- consoleIn, consoleOut, err := os.Pipe()
247- require.NoError(t, err)
248- t.Cleanup(func() {
249- _ = consoleIn.Close()
250- _ = consoleOut.Close()
251- })
252- terminal := portsmocks.NewMockTerminal(t)
253- terminal.EXPECT().In().Return(consoleIn).Maybe()
254- terminal.EXPECT().Out().Return(tt.out).Maybe()
255- terminal.EXPECT().Flush().Return(tt.flushErr).Maybe()
256-
257- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
258- defer cancel()
259- var intents []uint8
260- err = runAttachWithDeps(ctx, protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{
261- localDialer: sessionListDialer(t, nil),
262- terminal: func() ports.Terminal { return terminal },
263- interactiveConsole: interactiveProbe(true),
264- runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error {
265- intents = append(intents, request.Intent)
266- return nil
267- },
268- })
269- require.NoError(t, err, "an unusable console must not fail the attach")
270- require.Equal(t, []uint8{protocol.IntentAttach}, intents)
271- })
272- }
273-}
274-
275-func TestRunAttachWithDepsMissingSessionPreflightSkipsNonAttach(t *testing.T) {
276- for _, tt := range []struct {
277- name string
278- intent uint8
279- remote string
280- }{
281- {name: "create", intent: protocol.IntentNew},
282- {name: "remote", intent: protocol.IntentAttach, remote: "remote.example"},
283- {name: "ephemeral", intent: protocol.IntentEphemeral},
284- } {
285- t.Run(tt.name, func(t *testing.T) {
286- var intents []uint8
287- var promptOut strings.Builder
288- session := ""
289- if tt.intent != protocol.IntentEphemeral {
290- session = "scratch"
291- }
292- err := runAttachWithDeps(context.Background(), tt.intent, session, tt.remote, "", nil, runAttachDeps{
293- terminal: terminalStub(t, strings.NewReader("y\n"), &promptOut),
294- interactiveConsole: interactiveProbe(true),
295- runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
296- intents = append(intents, request.Intent)
297- if tt.remote != "" {
298- require.True(t, deps.Remote)
299- }
300- return nil
301- },
302- })
303- require.NoError(t, err)
304- require.Equal(t, []uint8{tt.intent}, intents)
305- require.Empty(t, promptOut.String(), "preflight must not run outside direct local attach")
306- })
307- }
308-}
309-
310-func TestRunAttachWithDepsMissingSessionPreflightUnavailableAttaches(t *testing.T) {
311- for _, tt := range []struct {
312- name string
313- dialErr error
314- cancel bool
315- }{
316- {name: "dial fails", dialErr: errors.New("socket unreachable")},
317- {name: "context cancelled", dialErr: context.Canceled, cancel: true},
318- } {
319- t.Run(tt.name, func(t *testing.T) {
320- stubLifecycleUnavailable(t)
321- ctx := context.Background()
322- if tt.cancel {
323- var cancel context.CancelFunc
324- ctx, cancel = context.WithCancel(ctx)
325- cancel()
326- }
327- var promptOut strings.Builder
328- var intents []uint8
329- err := runAttachWithDeps(ctx, protocol.IntentAttach, "scratch", "", "", nil, runAttachDeps{
330- localDialer: failingSessionListDialer(t, tt.dialErr),
331- terminal: terminalStub(t, strings.NewReader("y\n"), &promptOut),
332- interactiveConsole: interactiveProbe(true),
333- runClient: func(_ context.Context, _ client.Dependencies, request client.AttachRequest) error {
334- intents = append(intents, request.Intent)
335- return nil
336- },
337- })
338- require.NoError(t, err)
339- require.Equal(t, []uint8{protocol.IntentAttach}, intents)
340- require.Empty(t, promptOut.String(), "an undeterminable preflight must not prompt; the daemon rejection decides")
341- })
342- }
343-}
344-
345-// stubLifecycleUnavailable fails lifecycle acquisition so the preflight
346-// cannot fall back to the on-disk catalogue, independent of the machine's
347-// real daemon state.
348-func stubLifecycleUnavailable(t *testing.T) {
349- t.Helper()
350- originalProbe := daemonLifecycleProbe
351- t.Cleanup(func() { daemonLifecycleProbe = originalProbe })
352- daemonLifecycleProbe = fakeLifecycleProbe{err: errors.New("lifecycle unavailable")}
353-}
354-
355-// stalledListTransport parks the list exchange inside one operation until
356-// closed, letting tests exercise the preflight bound mid-flight. entered
357-// fires when the exchange reaches the parked operation.
358-func stalledListTransport(t *testing.T, blockOnSend bool) (*wiremocks.MockTransport, chan struct{}, chan struct{}) {
359- t.Helper()
360- entered := make(chan struct{})
361- closed := make(chan struct{})
362- transport := wiremocks.NewMockTransport(t)
363- block := func() {
364- close(entered)
365- <-closed
366- }
367- if blockOnSend {
368- transport.EXPECT().Send(mock.Anything).Run(func(wire.Envelope) { block() }).Return(errors.New("transport closed")).Once()
369- } else {
370- transport.EXPECT().Send(mock.Anything).Return(nil).Once()
371- transport.EXPECT().Recv().Run(func() { block() }).Return(wire.Envelope{}, errors.New("transport closed")).Once()
372- }
373- transport.EXPECT().Close().Run(func() {
374- select {
375- case <-closed:
376- default:
377- close(closed)
378- }
379- }).Return(nil)
380- return transport, entered, closed
381-}
382-
383-func TestListSessionsTimesOutOnSilentDaemon(t *testing.T) {
384- oldTimeout := preflightListTimeout
385- preflightListTimeout = 50 * time.Millisecond
386- defer func() { preflightListTimeout = oldTimeout }()
387-
388- transport, _, closed := stalledListTransport(t, false)
389- started := time.Now()
390- _, err := listSessionsWithDialer(context.Background(), func(context.Context) (wire.Transport, error) {
391- return transport, nil
392- })
393- elapsed := time.Since(started)
394- require.Error(t, err)
395- require.Less(t, elapsed, 5*time.Second, "a silent daemon must not stall the preflight past its bound")
396- select {
397- case <-closed:
398- default:
399- t.Fatal("preflight timeout must close the exchange transport")
400- }
401-}
402-
403-func TestListSessionsHonorsCancellation(t *testing.T) {
404- for _, tt := range []struct {
405- name string
406- blockOnSend bool
407- }{
408- {name: "blocked send", blockOnSend: true},
409- {name: "blocked recv", blockOnSend: false},
410- } {
411- t.Run(tt.name, func(t *testing.T) {
412- transport, entered, closed := stalledListTransport(t, tt.blockOnSend)
413- ctx, cancel := context.WithCancel(context.Background())
414- defer cancel()
415- done := make(chan error, 1)
416- go func() {
417- _, err := listSessionsWithDialer(ctx, func(context.Context) (wire.Transport, error) {
418- return transport, nil
419- })
420- done <- err
421- }()
422- // Wait until the exchange is blocked inside the transport
423- // before cancelling, so the test exercises in-flight
424- // cancellation rather than the earlier dial-path check.
425- select {
426- case <-entered:
427- case <-time.After(5 * time.Second):
428- t.Fatal("exchange did not reach the blocked transport")
429- }
430- started := time.Now()
431- cancel()
432- select {
433- case err := <-done:
434- require.ErrorIs(t, err, context.Canceled)
435- require.Less(t, time.Since(started), preflightListTimeout, "in-flight cancellation must beat the list bound")
436- case <-time.After(5 * time.Second):
437- t.Fatal("preflight did not release on context cancellation")
438- }
439- select {
440- case <-closed:
441- default:
442- t.Fatal("cancellation must close the exchange transport")
443- }
444- })
445- }
446-}
D internal/app/offline_kill_retry_test.go +0 -81
@@ -1,81 +0,0 @@
1-package app
2-
3-import (
4- "context"
5- "errors"
6- "os"
7- "path/filepath"
8- "testing"
9- "time"
10-
11- "github.com/stretchr/testify/require"
12-
13- "github.com/bnema/vev/internal/domain"
14- "github.com/bnema/vev/internal/persist"
15-)
16-
17-func TestRunOfflineNamedKillUsesOpenCatalogueSeam(t *testing.T) {
18- stateRoot := t.TempDir()
19- t.Setenv("XDG_STATE_HOME", stateRoot)
20- stateDir := filepath.Join(stateRoot, "vev")
21-
22- p := newTestPersister(t, stateDir)
23- now := time.Now().UnixNano()
24- require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
25- require.NoError(t, p.Close())
26-
27- original := openCatalogue
28- t.Cleanup(func() { openCatalogue = original })
29- wantErr := errors.New("open seam called")
30- called := false
31- openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
32- called = true
33- require.Equal(t, stateDir, gotStateDir)
34- return persist.OpenResult{}, wantErr
35- }
36-
37- err := runOfflineNamedKill(context.Background(), "named")
38- require.ErrorIs(t, err, wantErr)
39- require.True(t, called)
40-}
41-
42-func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) {
43- tests := []struct {
44- name string
45- crashHook func(*testing.T, string)
46- }{
47- {name: "normal"},
48- {
49- name: "stray tmp is ignored",
50- crashHook: func(t *testing.T, stateDir string) {
51- t.Helper()
52- storePath := persist.StorePath(stateDir)
53- require.NoError(t, os.WriteFile(storePath+".tmp", []byte("partial rewrite"), 0o600))
54- },
55- },
56- }
57-
58- for _, tt := range tests {
59- t.Run(tt.name, func(t *testing.T) {
60- stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
61- t.Setenv("XDG_STATE_HOME", stateRoot)
62- t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
63- stateDir := filepath.Join(stateRoot, "vev")
64-
65- id := domain.IncarnationID{1}
66- p := newTestPersister(t, stateDir)
67- now := time.Now().UnixNano()
68- require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
69- require.NoError(t, p.Close())
70- source := filepath.Join(stateDir, "snapshots", "sessions", id.String())
71- require.NoError(t, os.MkdirAll(source, 0o700))
72- require.NoError(t, os.WriteFile(filepath.Join(source, "sentinel"), []byte("durable"), 0o600))
73- if tt.crashHook != nil {
74- tt.crashHook(t, stateDir)
75- }
76-
77- require.NoError(t, runKill(context.Background(), "named", false, false))
78- require.NoDirExists(t, source)
79- })
80- }
81-}
D internal/app/remote_cache_config_test.go +0 -51
@@ -1,51 +0,0 @@
1-package app
2-
3-import (
4- "context"
5- "os"
6- "path/filepath"
7- "testing"
8- "time"
9-
10- remoteadapter "github.com/bnema/vev/internal/adapters/remote"
11- "github.com/bnema/vev/internal/domain"
12- "github.com/bnema/vev/internal/protocol"
13- "github.com/bnema/vev/internal/usecase/client"
14- "github.com/stretchr/testify/mock"
15- "github.com/stretchr/testify/require"
16-)
17-
18-func TestRunAttachThreadsAttachmentCacheConfig(t *testing.T) {
19- for _, remote := range []string{"", "remote.example"} {
20- for _, tt := range []struct {
21- name, text string
22- want domain.AttachmentCacheConfig
23- }{
24- {"default", "", domain.DefaultAttachmentCacheConfig()},
25- {"configured", "remote.attachment-cache-capacity = 3\nremote.attachment-cache-idle-timeout = 42s", domain.AttachmentCacheConfig{Enabled: true, Capacity: 3, IdleTimeout: 42 * time.Second}},
26- {"disabled", "remote.attachment-cache = off", domain.AttachmentCacheConfig{Enabled: false, Capacity: 8}},
27- {"warning with nil logger", "remote.attachment-cache-capacity = 0", domain.DefaultAttachmentCacheConfig()},
28- } {
29- t.Run(remote+"/"+tt.name, func(t *testing.T) {
30- path := filepath.Join(t.TempDir(), "config")
31- require.NoError(t, os.WriteFile(path, []byte(tt.text), 0600))
32- factory := newRemoteDialerFactoryMock(t)
33- if remote != "" {
34- factory.EXPECT().DialerForRemote(remote, "", remoteadapter.TransportQUIC, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
35- }
36- calls := 0
37- err := runAttachWithDeps(context.Background(), protocol.IntentNew, "work", remote, "", nil, runAttachDeps{
38- configPath: func() string { return path },
39- remoteDialerFactory: factory.DialerForRemote,
40- runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
41- calls++
42- require.Equal(t, tt.want, deps.AttachmentCache)
43- return nil
44- },
45- })
46- require.NoError(t, err)
47- require.Equal(t, 1, calls)
48- })
49- }
50- }
51-}
D internal/app/remote_dialer_factory_test.go +0 -78
@@ -1,78 +0,0 @@
1-package app
2-
3-import (
4- "fmt"
5- "log/slog"
6- "sync"
7- "testing"
8-
9- remoteadapter "github.com/bnema/vev/internal/adapters/remote"
10- "github.com/bnema/vev/internal/protocol/wire"
11-)
12-
13-type remoteDialerFactoryMock struct {
14- mu sync.Mutex
15- calls []*remoteDialerFactoryCall
16-}
17-
18-type remoteDialerFactoryExpecter struct{ mock *remoteDialerFactoryMock }
19-
20-type remoteDialerFactoryCall struct {
21- target, session string
22- mode remoteadapter.TransportMode
23- dialer wire.Dialer
24- err error
25- remaining int
26-}
27-
28-func newRemoteDialerFactoryMock(t *testing.T) *remoteDialerFactoryMock {
29- t.Helper()
30- m := &remoteDialerFactoryMock{}
31- t.Cleanup(func() {
32- m.mu.Lock()
33- defer m.mu.Unlock()
34- for _, call := range m.calls {
35- if call.remaining != 0 {
36- t.Errorf("unmet DialerForRemote(%q, %q, %q) calls: %d", call.target, call.session, call.mode, call.remaining)
37- }
38- }
39- })
40- return m
41-}
42-
43-func (m *remoteDialerFactoryMock) EXPECT() *remoteDialerFactoryExpecter {
44- return &remoteDialerFactoryExpecter{mock: m}
45-}
46-
47-func (e *remoteDialerFactoryExpecter) DialerForRemote(target, session string, mode remoteadapter.TransportMode, _ any) *remoteDialerFactoryCall {
48- call := &remoteDialerFactoryCall{target: target, session: session, mode: mode, remaining: 1}
49- e.mock.mu.Lock()
50- e.mock.calls = append(e.mock.calls, call)
51- e.mock.mu.Unlock()
52- return call
53-}
54-
55-func (c *remoteDialerFactoryCall) Return(dialer wire.Dialer, err error) *remoteDialerFactoryCall {
56- c.dialer, c.err = dialer, err
57- return c
58-}
59-
60-func (c *remoteDialerFactoryCall) Once() *remoteDialerFactoryCall { return c.Times(1) }
61-
62-func (c *remoteDialerFactoryCall) Times(n int) *remoteDialerFactoryCall {
63- c.remaining = n
64- return c
65-}
66-
67-func (m *remoteDialerFactoryMock) DialerForRemote(target, session string, mode remoteadapter.TransportMode, _ *slog.Logger) (wire.Dialer, error) {
68- m.mu.Lock()
69- defer m.mu.Unlock()
70- for _, call := range m.calls {
71- if call.remaining == 0 || call.target != target || call.session != session || call.mode != mode {
72- continue
73- }
74- call.remaining--
75- return call.dialer, call.err
76- }
77- return nil, fmt.Errorf("unexpected DialerForRemote(%q, %q, %q)", target, session, mode)
78-}
D internal/app/remote_final_acceptance_test.go +0 -178
@@ -1,178 +0,0 @@
1-//go:build linux
2-
3-package app
4-
5-import (
6- "bytes"
7- "context"
8- "io"
9- "log/slog"
10- "net"
11- "sync"
12- "testing"
13-
14- remoteadapter "github.com/bnema/vev/internal/adapters/remote"
15- "github.com/bnema/vev/internal/adapters/sessionwire"
16- "github.com/bnema/vev/internal/adapters/sshstdio"
17- "github.com/bnema/vev/internal/domain"
18- portsmocks "github.com/bnema/vev/internal/ports/mocks"
19- "github.com/bnema/vev/internal/protocol"
20- "github.com/bnema/vev/internal/protocol/wire"
21- "github.com/bnema/vev/internal/usecase/client"
22- "github.com/stretchr/testify/require"
23-)
24-
25-type acceptanceRemoteFactory struct {
26- mu sync.Mutex
27- calls []string
28- handoff map[string]protocol.AttachTarget
29- outputs chan string
30-}
31-
32-func (f *acceptanceRemoteFactory) DialerForRemote(target string, _ string, _ remoteadapter.TransportMode, _ *slog.Logger) (wire.Dialer, error) {
33- f.mu.Lock()
34- f.calls = append(f.calls, target)
35- targetHandoff := f.handoff[target]
36- f.mu.Unlock()
37- return acceptanceRemoteDialer{target: target, handoff: targetHandoff, outputs: f.outputs}, nil
38-}
39-
40-type acceptanceRemoteDialer struct {
41- target string
42- handoff protocol.AttachTarget
43- outputs chan<- string
44-}
45-
46-func (d acceptanceRemoteDialer) Dial(context.Context) (wire.Transport, error) {
47- clientConn, serverConn := net.Pipe()
48- clientTransport := sshstdio.NewTransport(clientConn, clientConn, clientConn.Close)
49- serverTransport := sshstdio.NewTransport(serverConn, serverConn, serverConn.Close)
50- go serveAcceptanceRemote(serverTransport, d.target, d.handoff, d.outputs)
51- return clientTransport, nil
52-}
53-
54-func serveAcceptanceRemote(raw wire.Transport, target string, handoff protocol.AttachTarget, outputs chan<- string) {
55- tr := sessionwire.NewServerConnection(raw)
56- defer func() { _ = tr.Close() }()
57- welcomed := false
58- for {
59- message, err := tr.ReceiveClient()
60- if err != nil {
61- return
62- }
63- if !welcomed {
64- if _, ok := message.(protocol.Hello); !ok {
65- continue
66- }
67- if err := tr.SendServer(protocol.Welcome{SessionID: target}); err != nil {
68- return
69- }
70- welcomed = true
71- continue
72- }
73- if handoff.Endpoint != "" {
74- _ = tr.SendServer(handoff)
75- for {
76- if _, err := tr.ReceiveClient(); err != nil {
77- return
78- }
79- }
80- }
81- data := []byte("remote:" + target)
82- if outputs != nil {
83- outputs <- string(data)
84- }
85- if err := tr.SendServer(protocol.Output{
86- Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data,
87- Context: &protocol.ViewContext{
88- Publication: 1,
89- Route: protocol.CommittedRouteIdentity{Target: protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "remote-fixture"}},
90- TabID: "tab-1", FocusedPaneID: "pane-1",
91- },
92- }); err != nil {
93- return
94- }
95- _ = tr.SendServer(protocol.Detached{Reason: protocol.ReasonDetach})
96- for {
97- if _, err := tr.ReceiveClient(); err != nil {
98- return
99- }
100- }
101- }
102-}
103-
104-type acceptanceRemoteTerminal struct {
105- in io.Reader
106- out bytes.Buffer
107-}
108-
109-func (t *acceptanceRemoteTerminal) EnterRaw() (func() error, error) {
110- return func() error { return nil }, nil
111-}
112-func (*acceptanceRemoteTerminal) Geometry() (domain.Geometry, error) {
113- return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
114-}
115-func (*acceptanceRemoteTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
116-func (t *acceptanceRemoteTerminal) In() io.Reader { return t.in }
117-func (t *acceptanceRemoteTerminal) Out() io.Writer { return &t.out }
118-func (*acceptanceRemoteTerminal) Flush() error { return nil }
119-
120-func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
121- outputs := make(chan string, 4)
122- factory := &acceptanceRemoteFactory{outputs: outputs}
123- hostStore := portsmocks.NewMockRemoteHostStore(t)
124- localCalls := 0
125- terminals := make([]*acceptanceRemoteTerminal, 0, 3)
126- deps := runAttachDeps{
127- hostStore: hostStore,
128- localDialer: func() wire.Dialer {
129- localCalls++
130- return acceptanceRemoteDialer{}
131- },
132- remoteDialerFactory: factory.DialerForRemote,
133- selectedRemoteTransport: string(remoteadapter.TransportStdio),
134- runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
135- term := &acceptanceRemoteTerminal{in: bytes.NewReader(nil)}
136- terminals = append(terminals, term)
137- deps.Terminal = term
138- deps.Logger = slog.New(slog.DiscardHandler)
139- deps.Remote = true
140- result := client.NewRunner(deps).Run(ctx, request)
141- if request.SessionName == "work" {
142- require.NoError(t, result, "the client handoff completes before the test injects the composition-root handoff")
143- return &client.AttachTargetError{Target: protocol.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: protocol.IntentAttach}}
144- }
145- return result
146- },
147- }
148-
149- probeRaw, err := (acceptanceRemoteDialer{
150- target: "picker-wire.example",
151- handoff: protocol.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: protocol.IntentAttach},
152- }).Dial(context.Background())
153- require.NoError(t, err)
154- wireTransport := sessionwire.NewClientConnection(probeRaw)
155- require.NoError(t, wireTransport.SendClient(protocol.Hello{
156- Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24},
157- }))
158- welcome, err := wireTransport.ReceiveServer()
159- require.NoError(t, err)
160- require.IsType(t, protocol.Welcome{}, welcome)
161- require.NoError(t, wireTransport.SendClient(protocol.Theme{}))
162- attachTargetMessage, err := wireTransport.ReceiveServer()
163- require.NoError(t, err)
164- target, ok := attachTargetMessage.(protocol.AttachTarget)
165- require.True(t, ok, "expected attach target, got %T", attachTargetMessage)
166- require.Equal(t, "picked.example", target.Endpoint)
167- require.NoError(t, wireTransport.Close())
168-
169- require.NoError(t, runAttachWithDeps(context.Background(), protocol.IntentAttach, "direct", "direct.example", "", nil, deps))
170- require.NoError(t, runAttachWithDeps(context.Background(), protocol.IntentAttach, "work", "picker.example", "", nil, deps))
171- factory.mu.Lock()
172- calls := append([]string(nil), factory.calls...)
173- factory.mu.Unlock()
174- require.Equal(t, []string{"direct.example", "picker.example", "picked.example"}, calls)
175- require.Zero(t, localCalls, "remote direct and picker connections must not create a local shadow")
176- require.Len(t, terminals, 3)
177- require.Equal(t, []string{"remote:direct.example", "remote:picker.example", "remote:picked.example"}, []string{<-outputs, <-outputs, <-outputs})
178-}
M internal/app/remote_hosts.go +73 -181
@@ -2,17 +2,13 @@ package app
22
33 import (
44 "context"
5- "errors"
65 "fmt"
76 "io"
8- "log/slog"
97 "os"
108 "sort"
119 "text/tabwriter"
1210
13- remoteadapter "github.com/bnema/vev/internal/adapters/remote"
1411 "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/platform"
1612 "github.com/bnema/vev/internal/ports"
1713 "github.com/bnema/vev/internal/protocol"
1814 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -24,36 +20,18 @@ const (
2420 hostActionList = "list"
2521 )
2622
27-// remoteHostDeps holds injectable seams for host management and remote listing.
2823 type remoteHostDeps struct {
29- stateDir func() string
30- store ports.RemoteHostStore
31- catalog ports.RemoteCatalogClient
32- localList func(context.Context) ([]protocol.SessionInfo, error)
33- stdout io.Writer
24+ connect func(context.Context) (ports.BrokerService, error)
25+ stdout io.Writer
3426 }
3527
3628 func defaultRemoteHostDeps() remoteHostDeps {
37- return remoteHostDeps{
38- stateDir: platform.StateDir,
39- catalog: remoteadapter.NewCatalogClient(),
40- localList: listLocalSessions,
41- stdout: os.Stdout,
42- }
29+ return remoteHostDeps{connect: connectProductionBroker, stdout: os.Stdout}
4330 }
4431
4532 func (d remoteHostDeps) withDefaults() remoteHostDeps {
46- if d.stateDir == nil {
47- d.stateDir = platform.StateDir
48- }
49- if d.store == nil {
50- d.store = remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(d.stateDir()))
51- }
52- if d.catalog == nil {
53- d.catalog = remoteadapter.NewCatalogClient()
54- }
55- if d.localList == nil {
56- d.localList = listLocalSessions
33+ if d.connect == nil {
34+ d.connect = connectProductionBroker
5735 }
5836 if d.stdout == nil {
5937 d.stdout = os.Stdout
@@ -61,200 +39,114 @@ func (d remoteHostDeps) withDefaults() remoteHostDeps {
6139 return d
6240 }
6341
64-func (d remoteHostDeps) hostStore() ports.RemoteHostStore {
65- return d.store
66-}
67-
6842 func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error {
69- _ = ctx
7043 deps = deps.withDefaults()
71-
44+ service, err := deps.connect(ctx)
45+ if err != nil {
46+ return err
47+ }
48+ defer func() { _ = service.Close() }()
7249 switch cmd.hostAction {
7350 case hostActionAdd:
74- return hostAdd(deps, cmd.hostTarget)
51+ if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil {
52+ return err
53+ }
54+ transport, err := remoteTransportModeFromEnv(os.Getenv(envRemoteTransport))
55+ if err != nil {
56+ return err
57+ }
58+ _, err = service.AddHost(ctx, cmd.hostTarget, remoteBrokerPolicy(transport))
59+ return err
7560 case hostActionRm:
76- return hostRm(deps, cmd.hostTarget)
61+ if err := domain.ValidateRemoteHostTarget(cmd.hostTarget); err != nil {
62+ return err
63+ }
64+ var registration domain.RemoteRegistration
65+ for _, daemon := range service.Snapshot().Daemons {
66+ if !daemon.Local && daemon.Endpoint == cmd.hostTarget {
67+ registration = daemon.Registration
68+ break
69+ }
70+ }
71+ if registration.Endpoint == "" {
72+ return fmt.Errorf("vev: unknown host %q", cmd.hostTarget)
73+ }
74+ removed, err := service.RemoveHost(ctx, registration)
75+ if err != nil {
76+ return err
77+ }
78+ if !removed {
79+ return fmt.Errorf("vev: unknown host %q", cmd.hostTarget)
80+ }
81+ return nil
7782 case hostActionList:
78- return hostList(deps)
83+ return printBrokerHosts(deps.stdout, service.Snapshot())
7984 default:
8085 return usagef("unknown host action %q", cmd.hostAction)
8186 }
8287 }
8388
84-func hostAdd(deps remoteHostDeps, target string) error {
85- if err := domain.ValidateRemoteHostTarget(target); err != nil {
86- return err
87- }
88- if err := deps.hostStore().AddPinned(target); err != nil {
89- return fmt.Errorf("vev: adding pinned host %q: %w", target, err)
90- }
91- return nil
92-}
93-
94-func hostRm(deps remoteHostDeps, target string) error {
95- if err := domain.ValidateRemoteHostTarget(target); err != nil {
96- return err
89+func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {
90+ if err := snapshot.Validate(); err != nil {
91+ return fmt.Errorf("vev: reading broker catalogue: %w", err)
9792 }
98- deleted, err := deps.hostStore().Remove(target)
99- if err != nil {
100- return fmt.Errorf("vev: removing host %q: %w", target, err)
101- }
102- if !deleted {
103- return fmt.Errorf("vev: unknown host %q", target)
93+ var endpoints []string
94+ for _, daemon := range snapshot.Daemons {
95+ if !daemon.Local {
96+ endpoints = append(endpoints, daemon.Endpoint)
97+ }
10498 }
105- return nil
106-}
107-
108-func hostList(deps remoteHostDeps) error {
109- hosts, err := mergeKnownHosts(deps)
110- if err != nil {
99+ sort.Strings(endpoints)
100+ if len(endpoints) == 0 {
101+ _, err := fmt.Fprintln(w, "no hosts")
111102 return err
112103 }
113- if len(hosts) == 0 {
114- _, _ = fmt.Fprintln(deps.stdout, "no hosts")
115- return nil
116- }
117- tw := tabwriter.NewWriter(deps.stdout, 0, 4, 2, ' ', 0)
104+ tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
118105 _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE")
119- for _, host := range hosts {
120- _, _ = fmt.Fprintf(tw, "%s\t%s\n", host.Target, remoteHostSource(host))
106+ for _, endpoint := range endpoints {
107+ _, _ = fmt.Fprintf(tw, "%s\tbroker\n", endpoint)
121108 }
122109 return tw.Flush()
123110 }
124111
125-func remoteHostSource(host domain.RemoteHost) string {
126- switch {
127- case host.Pinned && host.Learned:
128- return "pinned,learned"
129- case host.Pinned:
130- return "pinned"
131- case host.Learned:
132- return "learned"
133- default:
134- return ""
112+func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io.Writer) error {
113+ if err := snapshot.Validate(); err != nil {
114+ return fmt.Errorf("vev: reading broker catalogue: %w", err)
135115 }
136-}
137-
138-func runRemoteList(ctx context.Context, cmd command, deps remoteHostDeps) error {
139- deps = deps.withDefaults()
140-
141- hosts, err := mergeKnownHosts(deps)
142- if err != nil {
143- return err
144- }
145- if cmd.listHost != "" {
146- return listOneRemoteHost(ctx, deps, hosts, cmd.listHost)
147- }
148- return listAllSessions(ctx, deps, hosts)
149-}
150-
151-func listOneRemoteHost(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost, target string) error {
152- known := false
153- for _, host := range hosts {
154- if host.Target == target {
155- known = true
156- break
157- }
158- }
159- if !known {
160- return fmt.Errorf("vev: unknown host %q", target)
161- }
162- catalog, err := deps.catalog.List(ctx, target)
163- if err != nil {
164- return fmt.Errorf("vev: listing sessions on %q: %w", target, err)
165- }
166- printSessions(deps.stdout, catalogSessionsAsInfo(target, catalog.Sessions))
167- return nil
168-}
169-
170-// listAllSessions queries hosts sequentially for deterministic output ordering.
171-func listAllSessions(ctx context.Context, deps remoteHostDeps, hosts []domain.RemoteHost) error {
172- local, err := deps.localList(ctx)
173- if err != nil {
174- if local != nil {
175- printSessions(deps.stdout, local)
176- }
177- return err
178- }
179- all := append([]protocol.SessionInfo(nil), local...)
180- var hostErrs []error
181- for _, host := range hosts {
182- if err := ctx.Err(); err != nil {
183- printSessions(deps.stdout, all)
184- return err
185- }
186- catalog, listErr := deps.catalog.List(ctx, host.Target)
187- if listErr != nil {
188- hostErrs = append(hostErrs, fmt.Errorf("host %s: %w", host.Target, listErr))
116+ var sessions []protocol.SessionInfo
117+ found := cmd.listHost == ""
118+ for _, daemon := range snapshot.Daemons {
119+ if cmd.listHost != "" && (daemon.Local || daemon.Endpoint != cmd.listHost) {
189120 continue
190121 }
191- all = append(all, catalogSessionsAsInfo(host.Target, catalog.Sessions)...)
122+ found = true
123+ for i, session := range catalogSessionsAsInfo(daemon.Endpoint, daemon.Sessions) {
124+ if daemon.Local {
125+ session.Name = daemon.Sessions[i].Name
126+ }
127+ sessions = append(sessions, session)
128+ }
192129 }
193- printSessions(deps.stdout, all)
194- if len(hostErrs) > 0 {
195- return errors.Join(hostErrs...)
130+ if !found {
131+ return fmt.Errorf("vev: unknown host %q", cmd.listHost)
196132 }
133+ printSessions(stdout, sessions)
197134 return nil
198135 }
199136
200137 func catalogSessionsAsInfo(host string, sessions []catalogue.RemoteCatalogSession) []protocol.SessionInfo {
201138 out := make([]protocol.SessionInfo, 0, len(sessions))
202139 for _, session := range sessions {
203- info := protocol.SessionInfo{
204- Name: domain.RemoteSessionDisplay(session.Name, host),
205- Ephemeral: session.Ephemeral,
206- Tabs: catalogue.SaturateUint16(catalogue.CatalogTabCount(session)),
207- Attached: session.Attached,
208- }
140+ info := protocol.SessionInfo{Name: domain.RemoteSessionDisplay(session.Name, host), Ephemeral: session.Ephemeral, Tabs: catalogue.SaturateUint16(catalogue.CatalogTabCount(session)), Attached: session.Attached}
209141 switch session.State {
210142 case catalogue.RemoteCatalogSessionUp:
211143 info.State = protocol.SessionUp
212144 case catalogue.RemoteCatalogSessionDown:
213145 info.State = protocol.SessionDown
214- case catalogue.RemoteCatalogSessionBroken:
215- info.State = protocol.SessionBroken
216146 default:
217- slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State)
218147 info.State = protocol.SessionBroken
219148 }
220149 out = append(out, info)
221150 }
222151 return out
223152 }
224-
225-func mergeKnownHosts(deps remoteHostDeps) ([]domain.RemoteHost, error) {
226- pinned, learned, err := deps.hostStore().Hosts()
227- if err != nil {
228- return nil, fmt.Errorf("vev: reading hosts: %w", err)
229- }
230- return mergeRemoteHosts(pinned, learned), nil
231-}
232-
233-// mergeRemoteHosts returns pinned hosts in stored order, then learned-only
234-// hosts in lexical order. Duplicates keep first occurrence and mark both sources.
235-func mergeRemoteHosts(pinned, learned []domain.RemoteRegistration) []domain.RemoteHost {
236- pinned = domain.UniqueRemoteRegistrations(pinned)
237- learnedSet := make(map[string]struct{}, len(learned))
238- for _, record := range learned {
239- learnedSet[record.Endpoint] = struct{}{}
240- }
241- pinnedSet := make(map[string]struct{}, len(pinned))
242- out := make([]domain.RemoteHost, 0, len(pinned)+len(learned))
243- for _, record := range pinned {
244- pinnedSet[record.Endpoint] = struct{}{}
245- _, isLearned := learnedSet[record.Endpoint]
246- out = append(out, domain.RemoteHost{Target: record.Endpoint, Pinned: true, Learned: isLearned, Registration: record})
247- }
248- learnedOnly := make([]domain.RemoteRegistration, 0, len(learned))
249- for _, record := range learned {
250- if _, ok := pinnedSet[record.Endpoint]; !ok {
251- learnedOnly = append(learnedOnly, record)
252- }
253- }
254- learnedOnly = domain.UniqueRemoteRegistrations(learnedOnly)
255- sort.Slice(learnedOnly, func(i, j int) bool { return learnedOnly[i].Endpoint < learnedOnly[j].Endpoint })
256- for _, record := range learnedOnly {
257- out = append(out, domain.RemoteHost{Target: record.Endpoint, Learned: true, Registration: record})
258- }
259- return out
260-}
D internal/app/remote_preview.go +0 -81 collapsed to keep the page light
D internal/app/remote_test.go +0 -317 collapsed to keep the page light
M internal/app/run.go +198 -611 collapsed to keep the page light
A internal/app/run_broker_test.go +444 -0 collapsed to keep the page light
M internal/app/run_test.go +6 -657 collapsed to keep the page light
M internal/app/spawn.go +97 -52 collapsed to keep the page light
M internal/app/spawn_test.go +3 -10 collapsed to keep the page light
M internal/app/transport_observability_test.go +0 -72 collapsed to keep the page light
M internal/app/ui_driver.go +188 -710 collapsed to keep the page light
M internal/app/ui_driver_acceptance_test.go +222 -124 collapsed to keep the page light
A internal/app/ui_driver_client_test.go +351 -0 collapsed to keep the page light
A internal/app/ui_driver_fixture_test.go +349 -0 collapsed to keep the page light
M internal/app/ui_driver_test.go +56 -114 collapsed to keep the page light
M internal/app/web_daemon.go +64 -9 collapsed to keep the page light
A internal/app/web_daemon_broker_test.go +715 -0 collapsed to keep the page light
M internal/domain/config.go +18 -48 collapsed to keep the page light
M internal/ports/broker.go +29 -4 collapsed to keep the page light
A internal/ports/broker_acquisition.go +150 -0 collapsed to keep the page light
A internal/ports/broker_acquisition_test.go +57 -0 collapsed to keep the page light
M internal/ports/broker_pool.go +0 -28 collapsed to keep the page light
M internal/ports/broker_pool_test.go +10 -10 collapsed to keep the page light
M internal/ports/broker_store.go +106 -0 collapsed to keep the page light
M internal/ports/broker_store_test.go +138 -0 collapsed to keep the page light
A internal/ports/broker_stream_window.go +132 -0 collapsed to keep the page light
A internal/ports/broker_stream_window_test.go +86 -0 collapsed to keep the page light
M internal/ports/broker_test.go +25 -14 collapsed to keep the page light
A internal/ports/mocks/broker_acquisition_mock.go +198 -0 collapsed to keep the page light
M internal/ports/mocks/broker_mock.go +68 -15 collapsed to keep the page light
M internal/ports/mocks/broker_pool_mock.go +0 -95 collapsed to keep the page light
A internal/ports/mocks/session_admission_mock.go +90 -0 collapsed to keep the page light
A internal/ports/session_admission.go +176 -0 collapsed to keep the page light
A internal/ports/session_admission_test.go +274 -0 collapsed to keep the page light
M internal/ports/ui.go +27 -4 collapsed to keep the page light
A internal/ports/ui_status_test.go +32 -0 collapsed to keep the page light
M internal/protocol/routes.go +84 -0 collapsed to keep the page light
M internal/protocol/session.go +14 -14 collapsed to keep the page light
M internal/protocol/session_test.go +35 -0 collapsed to keep the page light
M internal/protocol/wire/broker.pb.go +19 -3 generated file: collapsed
M internal/protocol/wire/common.pb.go +149 -32 generated file: collapsed
M internal/protocol/wire/multiplex.pb.go +18 -4 generated file: collapsed
M internal/protocol/wire/schema/broker.proto +7 -0
@@ -172,6 +172,10 @@ message Reconcile {
172172 // stream names exactly how the daemon must admit it, while control and
173173 // observation streams carry none (0). name is validated session-name
174174 // authority for create-named and is empty for every other variant.
175+// start_mode is the explicit daemon-start authorization carried to the
176+// transport: observation and daemon-stop must be existing_only, while
177+// attach/creation and the explicit list/mutate controls may be
178+// start_if_needed. Zero is invalid and is refused by semantic validation.
175179 message OpenStream {
176180 BrokerStreamRef ref = 1;
177181 // Closed taxonomy: 1 = attachment, 2 = control, 3 = observation.
@@ -189,6 +193,9 @@ message OpenStream {
189193 uint32 admission = 9;
190194 // Validated session name for create-named; empty otherwise.
191195 string name = 10;
196+ // Closed daemon-start taxonomy (ports.BrokerDaemonStartMode): 1 =
197+ // existing_only, 2 = start_if_needed. Always present; zero is refused.
198+ uint32 start_mode = 11;
192199 }
193200
194201 // ClientStreamData carries one opaque client-to-daemon stream frame.
M internal/protocol/wire/schema/common.proto +12 -0 collapsed to keep the page light
M internal/protocol/wire/schema/multiplex.proto +5 -0 collapsed to keep the page light
M internal/protocol/wire/schema/session.proto +6 -2 collapsed to keep the page light
M internal/protocol/wire/session.pb.go +57 -57 generated file: collapsed
M internal/usecase/broker/pool.go +123 -26 collapsed to keep the page light
M internal/usecase/broker/pool_idle_test.go +3 -3 collapsed to keep the page light
M internal/usecase/broker/pool_review_test.go +37 -12 collapsed to keep the page light
M internal/usecase/broker/pool_test.go +148 -29 collapsed to keep the page light
M internal/usecase/broker/registry.go +78 -4 collapsed to keep the page light
A internal/usecase/broker/registry_binding_test.go +77 -0 collapsed to keep the page light
M internal/usecase/broker/registry_durable_test.go +6 -6 collapsed to keep the page light
M internal/usecase/broker/registry_local.go +27 -5 collapsed to keep the page light
M internal/usecase/broker/registry_local_test.go +1 -1
@@ -437,7 +437,7 @@ func TestRegistryLocalOnlyRefusesRestoredRemoteMembership(t *testing.T) {
437437 func TestPoolLocalStreamsShareOnePhysicalEntry(t *testing.T) {
438438 var connects atomic.Int32
439439 var physical *fakePhysical
440- pool, _ := setupPool(t, func(_ context.Context, endpoint ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) {
440+ pool, _ := setupPool(t, func(_ context.Context, endpoint ports.BrokerDialTarget) (ports.BrokerPhysicalConnection, error) {
441441 connects.Add(1)
442442 physical = &fakePhysical{endpoint: endpoint, done: make(chan struct{})}
443443 return physical, nil
A internal/usecase/broker/registry_lookup_test.go +57 -0 collapsed to keep the page light
M internal/usecase/broker/registry_membership_durable_test.go +7 -5 collapsed to keep the page light
M internal/usecase/broker/registry_membership_test.go +2 -2 collapsed to keep the page light
M internal/usecase/broker/registry_store_test.go +7 -7 collapsed to keep the page light
M internal/usecase/broker/registry_test.go +20 -7 collapsed to keep the page light
M internal/usecase/broker/service.go +27 -0 collapsed to keep the page light
M internal/usecase/broker/service_test.go +81 -7 collapsed to keep the page light
D internal/usecase/client/ack_queue_test.go +0 -209 collapsed to keep the page light
D internal/usecase/client/attachment_cache.go +0 -551 collapsed to keep the page light
D internal/usecase/client/attachment_cache_test.go +0 -612 collapsed to keep the page light
M internal/usecase/client/attachment_session.go +67 -15 collapsed to keep the page light
M internal/usecase/client/attachment_session_test.go +67 -7 collapsed to keep the page light
M internal/usecase/client/attachment_worker.go +90 -17 collapsed to keep the page light
M internal/usecase/client/attachment_worker_test.go +22 -6 collapsed to keep the page light
A internal/usecase/client/broker_operations.go +457 -0 collapsed to keep the page light
A internal/usecase/client/broker_operations_test.go +1117 -0 collapsed to keep the page light
D internal/usecase/client/client.go +0 -4096 large diff (4096 lines changed): collapsed
D internal/usecase/client/client_test.go +0 -1697 collapsed to keep the page light
D internal/usecase/client/clipboard_foreground_test.go +0 -107 collapsed to keep the page light
D internal/usecase/client/clipboard_intercept_internal_test.go +0 -186 collapsed to keep the page light
D internal/usecase/client/clipboard_intercept_test.go +0 -252 collapsed to keep the page light
D internal/usecase/client/host_registry_internal_test.go +0 -154 collapsed to keep the page light
D internal/usecase/client/hybrid_picker_route_test.go +0 -689 collapsed to keep the page light
A internal/usecase/client/initial_navigation.go +220 -0 collapsed to keep the page light
A internal/usecase/client/initial_navigation_test.go +502 -0 collapsed to keep the page light
D internal/usecase/client/inventory_attempt.go +0 -68 collapsed to keep the page light
D internal/usecase/client/inventory_attempt_test.go +0 -623 collapsed to keep the page light
D internal/usecase/client/inventory_cancellation_test.go +0 -56 collapsed to keep the page light
D internal/usecase/client/inventory_recovery_test.go +0 -91 collapsed to keep the page light
D internal/usecase/client/kitty_probe_test.go +0 -59 collapsed to keep the page light
M internal/usecase/client/lifecycle_p54a_test.go +15 -11 collapsed to keep the page light
D internal/usecase/client/navigation_fallback_capture_test.go +0 -84 collapsed to keep the page light
D internal/usecase/client/navigation_inventory.go +0 -352 collapsed to keep the page light
D internal/usecase/client/navigation_inventory_test.go +0 -348 collapsed to keep the page light
D internal/usecase/client/navigation_test.go +0 -52 collapsed to keep the page light
D internal/usecase/client/navigation_transition.go +0 -94 collapsed to keep the page light
D internal/usecase/client/navigation_transition_test.go +0 -100 collapsed to keep the page light
M internal/usecase/client/output_state.go +8 -0 collapsed to keep the page light
M internal/usecase/client/output_view_test.go +16 -2 collapsed to keep the page light
D internal/usecase/client/palette_attach_generation_test.go +0 -577 collapsed to keep the page light
D internal/usecase/client/palette_attach_test.go +0 -172 collapsed to keep the page light
M internal/usecase/client/picker_catalogue.go +143 -86 collapsed to keep the page light
M internal/usecase/client/picker_catalogue_test.go +71 -110 collapsed to keep the page light
M internal/usecase/client/picker_composition.go +2 -10 collapsed to keep the page light
D internal/usecase/client/picker_control.go +0 -87 collapsed to keep the page light
M internal/usecase/client/picker_controller.go +0 -12 collapsed to keep the page light
M internal/usecase/client/picker_controller_test.go +14 -13 collapsed to keep the page light
D internal/usecase/client/picker_e2e_support_test.go +0 -334 collapsed to keep the page light
D internal/usecase/client/picker_e2e_test.go +0 -300 collapsed to keep the page light
D internal/usecase/client/picker_input_test.go +0 -335 collapsed to keep the page light
D internal/usecase/client/picker_retire_test.go +0 -148 collapsed to keep the page light
D internal/usecase/client/reconnect.go +0 -70 collapsed to keep the page light
D internal/usecase/client/reconnect_toast_test.go +0 -1723 collapsed to keep the page light
D internal/usecase/client/remote_attach_validation_test.go +0 -63 collapsed to keep the page light
D internal/usecase/client/remote_cache_acceptance_test.go +0 -237 collapsed to keep the page light
D internal/usecase/client/route_deletion_test.go +0 -61 collapsed to keep the page light
D internal/usecase/client/route_observers.go +0 -67 collapsed to keep the page light
D internal/usecase/client/routes.go +0 -997 collapsed to keep the page light
D internal/usecase/client/routes_resume_identity_test.go +0 -36 collapsed to keep the page light
D internal/usecase/client/routes_test.go +0 -688 collapsed to keep the page light
A internal/usecase/client/runtime_primitives.go +67 -0 collapsed to keep the page light
D internal/usecase/client/same_peer_switch.go +0 -94 collapsed to keep the page light
D internal/usecase/client/same_peer_switch_test.go +0 -79 collapsed to keep the page light
D internal/usecase/client/session_connection.go +0 -99 collapsed to keep the page light
D internal/usecase/client/session_connection_internal_test.go +0 -33 collapsed to keep the page light
D internal/usecase/client/session_connection_test.go +0 -62 collapsed to keep the page light
A internal/usecase/client/session_environment.go +95 -0 collapsed to keep the page light
A internal/usecase/client/session_environment_test.go +94 -0 collapsed to keep the page light
M internal/usecase/client/supervisor.go +72 -28 collapsed to keep the page light
M internal/usecase/client/supervisor_attach.go +135 -21 collapsed to keep the page light
M internal/usecase/client/supervisor_attach_test.go +88 -38 collapsed to keep the page light
M internal/usecase/client/supervisor_integration_test.go +12 -0 collapsed to keep the page light
A internal/usecase/client/supervisor_presentation_test.go +81 -0 collapsed to keep the page light
M internal/usecase/client/supervisor_test.go +13 -0 collapsed to keep the page light
D internal/usecase/client/terminal_cause_test.go +0 -50 collapsed to keep the page light
D internal/usecase/client/terminal_input_pump_test.go +0 -505 collapsed to keep the page light
M internal/usecase/client/transition_ui.go +3 -6 collapsed to keep the page light
M internal/usecase/client/transition_ui_test.go +1 -1
@@ -20,7 +20,7 @@ func TestTransitionMessageDescribesSwitchAndStoppedRestore(t *testing.T) {
2020 Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1},
2121 SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"),
2222 }
23- stopped := protocol.AttachTarget{Endpoint: "remote", Session: "work", RemoteTarget: &stoppedTarget}
23+ stopped := protocol.AttachTarget{Endpoint: "remote", Session: "work", SessionTarget: &protocol.SessionAttachTarget{LifecycleID: stoppedTarget.LifecycleID, SessionName: stoppedTarget.SessionName, TabIndex: protocol.NoTabIndex, Stopped: true}}
2424
2525 require.Equal(t, "Switching to work@remote…", transitionMessage(live))
2626 require.Equal(t, "Starting work@remote…", transitionMessage(stopped))
D internal/usecase/client/transport_observability_test.go +0 -159 collapsed to keep the page light
D internal/usecase/client/truecolor_test.go +0 -33 collapsed to keep the page light
D internal/usecase/client/typed_connection_external_test.go +0 -176 collapsed to keep the page light
D internal/usecase/client/typed_connection_test.go +0 -399 collapsed to keep the page light
M internal/usecase/client/ui.go +47 -20 collapsed to keep the page light
M internal/usecase/client/ui_actions.go +5 -4 collapsed to keep the page light
A internal/usecase/client/ui_clock_test.go +71 -0 collapsed to keep the page light
M internal/usecase/client/ui_handoff.go +3 -3 collapsed to keep the page light
D internal/usecase/client/ui_handoff_test.go +0 -100 collapsed to keep the page light
D internal/usecase/client/ui_input_pump_test.go +0 -150 collapsed to keep the page light
D internal/usecase/client/ui_input_test.go +0 -69 collapsed to keep the page light
A internal/usecase/client/ui_presentation_test.go +149 -0 collapsed to keep the page light
D internal/usecase/client/ui_publication_test.go +0 -149 collapsed to keep the page light
D internal/usecase/client/ui_remote_test.go +0 -146 collapsed to keep the page light
D internal/usecase/client/ui_runner_test.go +0 -186 collapsed to keep the page light
M internal/usecase/client/ui_test.go +6 -4 collapsed to keep the page light
D internal/usecase/daemon/activation_policy_test.go +0 -132 collapsed to keep the page light
M internal/usecase/daemon/coderabbit_regression_test.go +9 -9 collapsed to keep the page light
M internal/usecase/daemon/daemon.go +34 -5 collapsed to keep the page light
M internal/usecase/daemon/exact_route_test.go +38 -4 collapsed to keep the page light
D internal/usecase/daemon/local_inventory_boundary_test.go +0 -606 collapsed to keep the page light
M internal/usecase/daemon/navigation_inventory.go +1 -1 collapsed to keep the page light
D internal/usecase/daemon/navigation_inventory_test.go +0 -298 collapsed to keep the page light
D internal/usecase/daemon/palette_origin_test.go +0 -79 collapsed to keep the page light
M internal/usecase/daemon/palette_test.go +0 -217 collapsed to keep the page light
M internal/usecase/daemon/picker.go +2 -16 collapsed to keep the page light
M internal/usecase/daemon/picker_control.go +1 -1 collapsed to keep the page light
A internal/usecase/daemon/picker_effect_helpers_test.go +19 -0 collapsed to keep the page light
M internal/usecase/daemon/picker_interaction_test.go +0 -28 collapsed to keep the page light
M internal/usecase/daemon/picker_preview_test.go +0 -129 collapsed to keep the page light
M internal/usecase/daemon/purge_test.go +1 -1 collapsed to keep the page light
M internal/usecase/daemon/remote_attach.go +10 -10 collapsed to keep the page light
D internal/usecase/daemon/remote_attach_test.go +0 -165 collapsed to keep the page light
D internal/usecase/daemon/remote_create_environment_test.go +0 -83 collapsed to keep the page light
D internal/usecase/daemon/remote_lifecycle_regression_test.go +0 -174 collapsed to keep the page light
D internal/usecase/daemon/remote_monitor_lifecycle_test.go +0 -259 collapsed to keep the page light
D internal/usecase/daemon/remote_picker_test.go +0 -951 collapsed to keep the page light
M internal/usecase/daemon/remote_preview.go +1 -1 collapsed to keep the page light
D internal/usecase/daemon/remote_preview_cache_test.go +0 -264 collapsed to keep the page light
D internal/usecase/daemon/remote_preview_capture_test.go +0 -169 collapsed to keep the page light
D internal/usecase/daemon/remote_route_test.go +0 -343 collapsed to keep the page light
D internal/usecase/daemon/remote_stopped_route_test.go +0 -280 collapsed to keep the page light
D internal/usecase/daemon/remote_stopped_selector_test.go +0 -66 collapsed to keep the page light
M internal/usecase/daemon/resume.go +2 -2 collapsed to keep the page light
M internal/usecase/daemon/resume_test.go +2 -2 collapsed to keep the page light
D internal/usecase/daemon/route_retirement_fence_test.go +0 -68 collapsed to keep the page light
D internal/usecase/daemon/route_retirement_test.go +0 -96 collapsed to keep the page light
A internal/usecase/daemon/session_admission.go +205 -0 collapsed to keep the page light
A internal/usecase/daemon/session_admission_test.go +660 -0 collapsed to keep the page light
D internal/usecase/daemon/session_inventory_test.go +0 -133 collapsed to keep the page light
D internal/usecase/remotes/monitor.go +0 -663 collapsed to keep the page light
D internal/usecase/remotes/monitor_runtime_test.go +0 -245 collapsed to keep the page light
D internal/usecase/remotes/monitor_test.go +0 -1137 collapsed to keep the page light
D internal/usecase/remotes/registry.go +0 -98 collapsed to keep the page light
D internal/usecase/remotes/registry_test.go +0 -206 collapsed to keep the page light
D internal/usecase/remotes/state.go +0 -306 collapsed to keep the page light
M scripts/demo/Dockerfile +5 -10 collapsed to keep the page light
M scripts/demo/compose.yaml +35 -11
@@ -1,17 +1,41 @@
1-name: vev-demo
1+name: vev-sandbox
22
33 services:
4- remote:
5- image: vev-demo
6- hostname: remote
4+ remote-a:
5+ image: vev-sandbox:${VEV_SANDBOX_TAG:-dev}
6+ hostname: remote-a
77 user: root
88 entrypoint: ["/usr/bin/sshd", "-D", "-e"]
9+ networks: [sandbox]
10+ environment: &sandbox-env
11+ XDG_CONFIG_HOME: /tmp/vev-config
12+ XDG_RUNTIME_DIR: /tmp/vev-runtime
13+ XDG_STATE_HOME: /tmp/vev-state
14+ tmpfs:
15+ - /run
16+ - /tmp
17+
18+ remote-b:
19+ image: vev-sandbox:${VEV_SANDBOX_TAG:-dev}
20+ hostname: remote-b
21+ user: root
22+ entrypoint: ["/usr/bin/sshd", "-D", "-e"]
23+ networks: [sandbox]
24+ environment: *sandbox-env
25+ tmpfs:
26+ - /run
27+ - /tmp
928
1029 client:
11- image: vev-demo
12- depends_on:
13- - remote
14- volumes:
15- - ../../scripts/demo:/tape:ro
16- - ../../scripts/demo/out:/home/demo/out
17- - ../../scripts/demo/out/local:/home/demo/.local
30+ image: vev-sandbox:${VEV_SANDBOX_TAG:-dev}
31+ hostname: client
32+ user: demo
33+ entrypoint: ["/bin/sh", "-c", "mkdir -m 700 -p /tmp/vev-config /tmp/vev-runtime /tmp/vev-state; trap : TERM INT; sleep infinity & wait"]
34+ networks: [sandbox]
35+ environment: *sandbox-env
36+ tmpfs:
37+ - /tmp
38+
39+networks:
40+ sandbox:
41+ internal: true
A scripts/demo/sandbox-run.sh +44 -0 collapsed to keep the page light
A scripts/demo/sandbox_acceptance.py +112 -0 collapsed to keep the page light
M scripts/remote-picker-harness/acceptance.py +29 -1 collapsed to keep the page light
M scripts/remote-picker-harness/navigation_repro.py +20 -1 collapsed to keep the page light

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close