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
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "go/ast"
5 "go/parser"6 "go/parser"
6 "go/token"7 "go/token"
7 "io/fs"8 "io/fs"
@@ -87,12 +88,6 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) {
87 }{88 }{
88 {"usecase accepts semantic protocol", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol", false, true},89 {"usecase accepts semantic protocol", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol", false, true},
89 {"usecase rejects wire", modulePath + "/internal/usecase/daemon", modulePath + "/internal/protocol/wire", false, false},90 {"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},
96 {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false},91 {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false},
97 {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false},92 {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false},
98 {"pkg rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", false, false},93 {"pkg rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", false, false},
@@ -110,26 +105,17 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) {
110 {"broker subpackage accepts broker root", modulePath + "/internal/usecase/broker/streams", modulePath + "/internal/usecase/broker", false, true},105 {"broker subpackage accepts broker root", modulePath + "/internal/usecase/broker/streams", modulePath + "/internal/usecase/broker", false, true},
111 {"broker rejects client", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/client", false, false},106 {"broker rejects client", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/client", false, false},
112 {"broker rejects daemon", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/daemon", false, false},107 {"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},
114 {"broker rejects sibling usecase", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/picker", false, false},108 {"broker rejects sibling usecase", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/picker", false, false},
115 {"broker rejects adapter", modulePath + "/internal/usecase/broker", modulePath + "/internal/adapters/ipc", false, false},109 {"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},
117 {"broker rejects wire", modulePath + "/internal/usecase/broker", modulePath + "/internal/protocol/wire", false, false},110 {"broker rejects wire", modulePath + "/internal/usecase/broker", modulePath + "/internal/protocol/wire", false, false},
118 {"broker rejects app", modulePath + "/internal/usecase/broker", modulePath + "/internal/app", false, false},111 {"broker rejects app", modulePath + "/internal/usecase/broker", modulePath + "/internal/app", false, false},
119 {"broker rejects persist", modulePath + "/internal/usecase/broker", modulePath + "/internal/persist", false, false},112 {"broker rejects persist", modulePath + "/internal/usecase/broker", modulePath + "/internal/persist", false, false},
120 // P1.1 client/daemon must not own broker, each other, or remote discovery.113 // P1.1 client/daemon must not own broker, each other, or remote discovery.
121 {"client rejects daemon", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/daemon", false, false},114 {"client rejects daemon", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/daemon", false, false},
122 {"client rejects broker", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/broker", false, false},115 {"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},
125 {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false},116 {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false},
126 {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false},117 {"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},
129 {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false},118 {"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},
133 }119 }
134 for _, tt := range tests {120 for _, tt := range tests {
135 t.Run(tt.name, func(t *testing.T) {121 t.Run(tt.name, func(t *testing.T) {
@@ -223,14 +209,12 @@ func dependencyAllowed(source, target string, testFile bool) (bool, error) {
223 209
224 // packageImportDenied encodes ADR 001 ownership at package granularity for210 // packageImportDenied encodes ADR 001 ownership at package granularity for
225 // production files. Layer rules alone permit any usecase-to-usecase import;211 // production files. Layer rules alone permit any usecase-to-usecase import;
226-// these denies keep broker, client, daemon, and remotes from owning each
227 // other, and keep the broker free of every sibling use case. Test files stay212 // other, and keep the broker free of every sibling use case. Test files stay
228 // exempt so composition tests can wire adapters and orchestrators together.213 // exempt so composition tests can wire adapters and orchestrators together.
229 func packageImportDenied(source, target string) bool {214 func packageImportDenied(source, target string) bool {
230 brokerPkg := modulePath + "/internal/usecase/broker"215 brokerPkg := modulePath + "/internal/usecase/broker"
231 clientPkg := modulePath + "/internal/usecase/client"216 clientPkg := modulePath + "/internal/usecase/client"
232 daemonPkg := modulePath + "/internal/usecase/daemon"217 daemonPkg := modulePath + "/internal/usecase/daemon"
233- remotesPkg := modulePath + "/internal/usecase/remotes"
234 usecasePrefix := modulePath + "/internal/usecase/"218 usecasePrefix := modulePath + "/internal/usecase/"
235 under := func(path, root string) bool {219 under := func(path, root string) bool {
236 return path == root || strings.HasPrefix(path, root+"/")220 return path == root || strings.HasPrefix(path, root+"/")
@@ -239,10 +223,8 @@ func packageImportDenied(source, target string) bool {
239 case under(source, brokerPkg):223 case under(source, brokerPkg):
240 return !under(target, brokerPkg) && strings.HasPrefix(target, usecasePrefix)224 return !under(target, brokerPkg) && strings.HasPrefix(target, usecasePrefix)
241 case under(source, clientPkg):225 case under(source, clientPkg):
242- return under(target, daemonPkg) || under(target, brokerPkg) || under(target, remotesPkg)226+ return under(target, daemonPkg) || under(target, brokerPkg)
243 case under(source, daemonPkg):227 case under(source, daemonPkg):
244- return under(target, clientPkg) || under(target, brokerPkg) || under(target, remotesPkg)
245- case under(source, remotesPkg):
246 return under(target, clientPkg) || under(target, daemonPkg) || under(target, brokerPkg)228 return under(target, clientPkg) || under(target, daemonPkg) || under(target, brokerPkg)
247 default:229 default:
248 return false230 return false
@@ -371,3 +353,85 @@ func inspectExternalDependencies(t *testing.T) []string {
371 sort.Strings(violations)353 sort.Strings(violations)
372 return violations354 return violations
373 }355 }
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) {
34 t.Fatal(err)34 t.Fatal(err)
35 }35 }
36 text := string(shim)36 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} {
38 if !strings.Contains(text, want) {38 if !strings.Contains(text, want) {
39 t.Errorf("seam does not retain %q:\n%s", want, text)39 t.Errorf("seam does not retain %q:\n%s", want, text)
40 }40 }
M cmd/vev-perf-harness/hangup_test.go +8 -8
@@ -110,11 +110,11 @@ func TestClientTracePairsAfterPTYHangup(t *testing.T) {
110 }110 }
111 }()111 }()
112 112
113- // Wait until the client is attached with a receive pump blocked mid-frame:113+ // Wait until the broker-backed client reaches a real attached shell before
114- // its trace then holds one more adapter_receive_start than end. That in-flight114+ // severing its terminal. Session carriage tracing now belongs to the broker,
115- // span is exactly what an ungraceful death truncates.115+ // not the thin client process.
116- if err := waitForInflightReceive(clientMapping.TracePath, 10*time.Second); err != nil {116+ if err := client.waitForTerminalReady(); err != nil {
117- t.Fatalf("client never reached a blocked receive: %v", err)117+ t.Fatalf("client never reached an attached shell: %v", err)
118 }118 }
119 119
120 // Sever the controlling terminal with no graceful exit. This is the harness120 // Sever the controlling terminal with no graceful exit. This is the harness
@@ -183,7 +183,7 @@ func TestClientGracefulShutdownDrainsTracedStdioDescendant(t *testing.T) {
183 var command roleCommand183 var command roleCommand
184 switch pm.Role {184 switch pm.Role {
185 case "daemon":185 case "daemon":
186- command = roleCommand{Args: []string{"--daemon"}}186+ command = routeRoleArgs(s, pm, transport{})
187 case "ssh_stdio_peer":187 case "ssh_stdio_peer":
188 // The transport-neutral _stdio mode requests an ephemeral session.188 // The transport-neutral _stdio mode requests an ephemeral session.
189 command = roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}}189 command = roleCommand{Args: []string{"_stdio"}, Transport: transport{ID: "ssh_stdio", Kind: "ssh_stdio"}}
@@ -231,8 +231,8 @@ func TestClientGracefulShutdownDrainsTracedStdioDescendant(t *testing.T) {
231 }231 }
232 }()232 }()
233 233
234- if err := waitForInflightReceive(peerMapping.TracePath, 10*time.Second); err != nil {234+ if err := client.waitForTerminalReady(); err != nil {
235- t.Fatalf("stdio descendant never reached blocked receive: %v", err)235+ t.Fatalf("stdio client never reached an attached shell: %v", err)
236 }236 }
237 processGroup := client.cmd.Process.Pid237 processGroup := client.cmd.Process.Pid
238 if err := client.Close(); err != nil {238 if err := client.Close(); err != nil {
M cmd/vev-perf-harness/launcher.go +200 -50
@@ -5,6 +5,7 @@ package main
5 import (5 import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8+ "encoding/json"
8 "errors"9 "errors"
9 "fmt"10 "fmt"
10 "io"11 "io"
@@ -66,8 +67,10 @@ type cliLauncher struct {
66 }67 }
67 68
68 type peerRoute struct {69 type peerRoute struct {
69- mapping processMapping70+ mapping processMapping
70- command roleCommand71+ command roleCommand
72+ runtimeDir string
73+ stateDir string
71 }74 }
72 75
73 type preparedPeer struct {76 type preparedPeer struct {
@@ -91,6 +94,46 @@ type terminalOutput interface {
91 Close() error94 Close() error
92 }95 }
93 96
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+
94 var setPTYWinsize = rawterm.SetWinsize137 var setPTYWinsize = rawterm.SetWinsize
95 138
96 type cliProcess struct {139 type cliProcess struct {
@@ -108,7 +151,12 @@ type cliProcess struct {
108 shutdown func() error151 shutdown func() error
109 cleanupRuntime func() error152 cleanupRuntime func() error
110 terminalReady bool153 terminalReady bool
111- readyPath string154+ readyCommand *exec.Cmd
155+ startCommand *exec.Cmd
156+ configureCommand *exec.Cmd
157+ serverOutput *synchronizedBuffer
158+ peerCommand *exec.Cmd
159+ peerDone chan error
112 closeResult error160 closeResult error
113 }161 }
114 162
@@ -146,25 +194,80 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
146 // a role working directory: daemon-owned subprocess cleanup may otherwise194 // a role working directory: daemon-owned subprocess cleanup may otherwise
147 // treat the evidence directory as its working tree and remove a preallocated195 // treat the evidence directory as its working tree and remove a preallocated
148 // trace while a later repetition is being merged.196 // 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)...)
150 cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir,198 cmd.Env = append(cmd.Env, "XDG_RUNTIME_DIR="+runtimeDir,
151- "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "TERM=xterm-256color", "VEV_PERF_BIN="+bin)199+ "XDG_STATE_HOME="+filepath.Join(runDir, "state"), "XDG_CONFIG_HOME="+filepath.Join(runDir, "config"), "TERM=xterm-256color", "SHELL=/bin/sh", "VEV_PERF_BIN="+bin)
152- if m.Role == "client" {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
153 l.mu.Lock()228 l.mu.Lock()
154 peer, routed := l.peers[runDir]229 peer, routed := l.peers[runDir]
155 l.mu.Unlock()230 l.mu.Unlock()
156 if routed {231 if routed {
157- mode, err := remoteMode(peer.command.Transport)232+ mode, modeErr := remoteMode(peer.command.Transport)
158- if err != nil {233+ if modeErr != nil {
159- return nil, err234+ return nil, modeErr
160 }235 }
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+ }()
162 }269 }
163 }270 }
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")}
168 if m.Role == "daemon" {271 if m.Role == "daemon" {
169 p.cleanupRuntime = func() error { return l.releaseRuntime(runDir) }272 p.cleanupRuntime = func() error { return l.releaseRuntime(runDir) }
170 }273 }
@@ -197,7 +300,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
197 return nil, err300 return nil, err
198 }301 }
199 _ = slave.Close()302 _ = 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{})
201 // Stop workload generation through the attached shell before any signal.304 // Stop workload generation through the attached shell before any signal.
202 // A normal shell exit makes the daemon detach the client; the client then305 // A normal shell exit makes the daemon detach the client; the client then
203 // closes and waits for its ssh transport, allowing a traced _stdio306 // closes and waits for its ssh transport, allowing a traced _stdio
@@ -213,7 +316,7 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
213 if err != nil {316 if err != nil {
214 return nil, err317 return nil, err
215 }318 }
216- cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull319+ cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, p.serverOutput, p.serverOutput
217 if err := cmd.Start(); err != nil {320 if err := cmd.Start(); err != nil {
218 _ = devNull.Close()321 _ = devNull.Close()
219 if p.cleanupRuntime != nil {322 if p.cleanupRuntime != nil {
@@ -224,9 +327,9 @@ func (l *cliLauncher) Launch(m processMapping, role roleCommand) (launchedProces
224 _ = devNull.Close()327 _ = devNull.Close()
225 }328 }
226 if m.Role == "daemon" {329 if m.Role == "daemon" {
227- // Stop the foreground daemon through its public CLI before its bounded330+ // Ask the daemon owned by the foreground broker to stop before the
228- // process-group fallback. This lets blocked carriage operations close and331+ // broker's bounded process-group fallback. This lets blocked carriage
229- // serialize their failed end marks instead of being cut off by SIGKILL.332+ // operations serialize their failed end marks before either process exits.
230 p.shutdown = func() error {333 p.shutdown = func() error {
231 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)334 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
232 defer cancel()335 defer cancel()
@@ -310,7 +413,13 @@ func (l *cliLauncher) preparePeer(m processMapping, role roleCommand) (launchedP
310 if len(role.Args) != 1 || role.Args[0] != "_stdio" {413 if len(role.Args) != 1 || role.Args[0] != "_stdio" {
311 return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args))414 return nil, fail(fmt.Errorf("unsupported public peer command %q", role.Args))
312 }415 }
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+ }
314 shim := filepath.Join(runDir, "ssh")423 shim := filepath.Join(runDir, "ssh")
315 // The SSH command seam forwards the public _stdio entrypoint with the424 // The SSH command seam forwards the public _stdio entrypoint with the
316 // peer's unique trace identity, so the peer carrying client traffic is the425 // 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
318 body := fmt.Sprintf(`#!/bin/sh427 body := fmt.Sprintf(`#!/bin/sh
319 set -eu428 set -eu
320 case "$*" in429 case "$*" in
321- *"_stdio"*)430+ *"_broker-mux-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 _stdio431+ 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
323 ;;432 ;;
324 *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;433 *) echo 'vev harness ssh seam rejected non-vev command' >&2; exit 64 ;;
325 esac434 esac
@@ -402,35 +511,48 @@ func (p *cliProcess) copyTerminal() {
402 }511 }
403 }512 }
404 513
405-// WaitReady confirms the explicitly launched public daemon owns its unique514+// WaitReady executes the production dial-only readiness contract under the
406-// socket before a client is launched. Without this barrier, the normal client515+// exact XDG environment shared by the servers and clients. Its bounded JSON
407-// auto-spawn behavior can create an unmanifested daemon process during startup.516+// stdout, stderr and owner-process death are retained in any failure.
408 func (p *cliProcess) WaitReady() error {517 func (p *cliProcess) WaitReady() error {
409- if p.readyPath == "" {518+ if p.startCommand != nil {
410- return errors.New("daemon readiness path is empty")519+ // The first control stream is only a start authorization. The daemon may
411- }520+ // close that stream while publishing its first catalogue, so readiness is
412- ticker := time.NewTicker(10 * time.Millisecond)521+ // decided exclusively by the semantic probe below.
413- defer ticker.Stop()522+ _, _ = p.startCommand.CombinedOutput()
414- timer := time.NewTimer(10 * time.Second)523+ }
415- defer timer.Stop()524+ if p.readyCommand == nil {
416- for {525+ return errors.New("broker readiness command is empty")
417- if info, err := os.Stat(p.readyPath); err == nil {526+ }
418- if info.Mode()&os.ModeSocket == 0 {527+ var stdout, stderr bytes.Buffer
419- return fmt.Errorf("daemon readiness path %s is not a socket", p.readyPath)528+ p.readyCommand.Stdout, p.readyCommand.Stderr = &stdout, &stderr
420- }529+ probeDone := make(chan error, 1)
421- return nil530+ 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())
422 }538 }
423- select {539+ var report struct {
424- case err := <-p.waitErr:540+ Status string `json:"status"`
425- p.waitErr <- err541+ }
426- if err == nil {542+ if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &report); err != nil || report.Status != "ready" {
427- return errors.New("daemon exited before readiness")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)
428 }548 }
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)
433 }549 }
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())
434 }556 }
435 }557 }
436 558
@@ -483,16 +605,20 @@ func (p *cliProcess) Measure(input []byte, injected, flushed func() error) error
483 func (p *cliProcess) waitForTerminalReady() error {605 func (p *cliProcess) waitForTerminalReady() error {
484 timer := time.NewTimer(10 * time.Second)606 timer := time.NewTimer(10 * time.Second)
485 defer timer.Stop()607 defer timer.Stop()
608+ var observed []byte
486 for {609 for {
487 select {610 select {
488 case chunk, ok := <-p.chunks:611 case chunk, ok := <-p.chunks:
489 if !ok {612 if !ok {
490 return p.terminalClosedError("readiness output")613 return p.terminalClosedError("readiness output")
491 }614 }
492- if len(chunk) == 0 {615+ observed = append(observed, chunk...)
493- continue616+ 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:]...)
494 }621 }
495- return nil
496 case err := <-p.waitErr:622 case err := <-p.waitErr:
497 // Keep the exit result available to Close, which owns reaping.623 // Keep the exit result available to Close, which owns reaping.
498 p.waitErr <- err624 p.waitErr <- err
@@ -501,11 +627,21 @@ func (p *cliProcess) waitForTerminalReady() error {
501 }627 }
502 return fmt.Errorf("client exited before readiness output: %w", err)628 return fmt.Errorf("client exited before readiness output: %w", err)
503 case <-timer.C:629 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+ }
504 return errors.New("timed out waiting for client readiness output")633 return errors.New("timed out waiting for client readiness output")
505 }634 }
506 }635 }
507 }636 }
508 637
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+
509 func (p *cliProcess) terminalClosedError(boundary string) error {645 func (p *cliProcess) terminalClosedError(boundary string) error {
510 if p.waitErr != nil {646 if p.waitErr != nil {
511 select {647 select {
@@ -541,6 +677,9 @@ func (p *cliProcess) waitForTerminalMarker(marker, input []byte) error {
541 }677 }
542 return fmt.Errorf("client exited before application output: %w", err)678 return fmt.Errorf("client exited before application output: %w", err)
543 case <-timer.C:679 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+ }
544 return errors.New("timed out waiting for application terminal output")683 return errors.New("timed out waiting for application terminal output")
545 }684 }
546 }685 }
@@ -670,6 +809,17 @@ func (p *cliProcess) Close() error {
670 if drainDone != nil {809 if drainDone != nil {
671 <-drainDone810 <-drainDone
672 }811 }
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+ }
673 if p.output != nil {823 if p.output != nil {
674 if err := p.output.Close(); p.closeResult == nil {824 if err := p.output.Close(); p.closeResult == nil {
675 p.closeResult = err825 p.closeResult = err
M cmd/vev-perf-harness/launcher_test.go +4 -15
@@ -4,7 +4,6 @@ package main
4 4
5 import (5 import (
6 "bytes"6 "bytes"
7- "context"
8 "errors"7 "errors"
9 "fmt"8 "fmt"
10 "net"9 "net"
@@ -337,19 +336,9 @@ func TestCLIProcessCloseEscalationOutcome(t *testing.T) {
337 }336 }
338 }337 }
339 338
340-func TestCLIProcessWaitReadyRequiresDaemonSocket(t *testing.T) {339+func TestCLIProcessWaitReadyRunsJSONProbe(t *testing.T) {
341- path := filepath.Join(t.TempDir(), "daemon.sock")340+ command := exec.Command("sh", "-c", `printf '%s\n' '{"schema":"vev.broker-ready/v1","status":"ready"}'`)
342- var lc net.ListenConfig341+ if err := (&cliProcess{readyCommand: command, waitErr: make(chan error, 1)}).WaitReady(); err != nil {
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 {
353 t.Fatal(err)342 t.Fatal(err)
354 }343 }
355 }344 }
@@ -612,7 +601,7 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) {
612 role string601 role string
613 want []string602 want []string
614 }{603 }{
615- {"daemon", []string{"--daemon"}},604+ {"daemon", []string{"_broker-production-serve"}},
616 {"client", []string{"new", "perf-s-001"}},605 {"client", []string{"new", "perf-s-001"}},
617 {"ssh_stdio_peer", []string{"_stdio"}},606 {"ssh_stdio_peer", []string{"_stdio"}},
618 } {607 } {
M cmd/vev-perf-harness/runner.go +7 -3
@@ -387,7 +387,9 @@ func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand
387 remote := []string{"attach", "harness@127.0.0.1"}387 remote := []string{"attach", "harness@127.0.0.1"}
388 switch m.Role {388 switch m.Role {
389 case "daemon":389 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"}}
391 case "client":393 case "client":
392 switch s.Transport {394 switch s.Transport {
393 case "local":395 case "local":
@@ -423,9 +425,11 @@ func launchOrder(mappings []processMapping) []processMapping {
423 out := append([]processMapping(nil), mappings...)425 out := append([]processMapping(nil), mappings...)
424 priority := func(role string) int {426 priority := func(role string) int {
425 switch role {427 switch role {
426- case "daemon":
427- return 0
428 case "ssh_stdio_peer":428 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":
429 return 1433 return 1
430 case "client":434 case "client":
431 return 2435 return 2
M cmd/vev-perf-harness/runner_test.go +9 -4
@@ -102,8 +102,13 @@ func TestHarnessCanonicalLocalRolesAreIsolatedAcrossRepetitions(t *testing.T) {
102 }102 }
103 }103 }
104 }104 }
105- if _, err := os.Stat(filepath.Join(runDir, "runtime", "vev", "daemon.sock")); !errors.Is(err, os.ErrNotExist) {105+ for _, socket := range []string{
106- t.Fatalf("run %d left daemon socket for the next repetition: %v", run, err)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+ }
107 }112 }
108 }113 }
109 }114 }
@@ -276,10 +281,10 @@ func TestHarnessFakeRunnerRoutesClientToPeerAndCleansEveryRole(t *testing.T) {
276 if _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, s, 1, raw); err != nil {281 if _, err := h.runOne(options{out: dir, warmup: time.Second, duration: minimumDuration, repetitions: minimumRepetitions}, manifest{}, s, 1, raw); err != nil {
277 t.Fatal(err)282 t.Fatal(err)
278 }283 }
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" {
280 t.Fatalf("dependency launch order=%+v", l.mappings)285 t.Fatalf("dependency launch order=%+v", l.mappings)
281 }286 }
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" {
283 t.Fatalf("commands do not connect client through declared peer: %+v", l.commands)288 t.Fatalf("commands do not connect client through declared peer: %+v", l.commands)
284 }289 }
285 for i, p := range l.process {290 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
64 composed only by the hidden offline broker entry points (`_broker-serve`,64 composed only by the hidden offline broker entry points (`_broker-serve`,
65 `_broker-launcher`, and `_broker-status`).65 `_broker-launcher`, and `_broker-status`).
66 - `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.66 - `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`.
68 - `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.68 - `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.
69 - `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.69 - `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.
71 - `pkg`: reusable packages that never import `internal`.71 - `pkg`: reusable packages that never import `internal`.
72 72
73 ## Dependency direction73 ## Dependency direction
@@ -86,16 +86,18 @@ main → app → usecase → ports
86 86
87 ## UI observation and control87 ## UI observation and control
88 88
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.
90 90
91 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.91 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.
92 92
93 ## Browser terminal93 ## Browser terminal
94 94
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.
96 96
97 ## Client-owned picker presentation97 ## Client-owned picker presentation
98 98
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+
99 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.101 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.
100 102
101 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.103 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
116 118
117 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.119 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.
118 120
119-## Remote hosts121+## Broker-owned hosts and daemon composition
120-122+
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.123+`vev _broker-ready --require local-authority|local-catalogue` is an internal,
122-124+dial-only machine probe. It connects directly to the selected broker IPC
123-Current state: the daemon-side monitor is deliberately still active. `Serve`125+socket, registers, subscribes, and validates complete snapshots. It never
124-starts the runner through `daemon.WithRemoteMonitor` and joins it inside the126+ensures or spawns, opens a logical stream, reconciles, or mutates. An optional
125-bounded remote-shutdown budget, and the daemon keeps its own remote directory127+`--offline-root ABS` selects the offline layout. Output is exactly one bounded
126-and host registry. That daemon-owned monitoring (and the host registry that128+`vev.broker-ready/v1` JSON line with decimal-string epoch/revision IDs. Exit
127-feeds it) belongs to the coordinated P7 removal set; it is not removed,129+codes are 0 ready, 1 internal/write, 2 arguments, 3 timeout, 4 terminal
128-gated, or polled differently before that cutover, and no polling gate is130+security/protocol/configuration failure, and 5 cancellation.
129-introduced here (GO-001, deferred to P7).131+
130-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.
131-## Prepared local inventory boundary (Plan 001 P4.3, preparation only)133+
132-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.
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).
187 135
188 ## Session composition136 ## Session composition
189 137
@@ -242,11 +190,20 @@ are not keys. Identity plus complete trust, launch, isolation, transport,
242 protocol, catalogue, and environment policy select one physical connection;190 protocol, catalogue, and environment policy select one physical connection;
243 authenticated identity and policy are checked again after connection.191 authenticated identity and policy are checked again after connection.
244 192
245-Clients receive pool-issued epoch-scoped connection IDs and supply strictly193+Clients receive pool-issued epoch-scoped connection IDs and allocate their own
246-increasing stream IDs. Failed admitted opens consume their IDs. Active clients,194+logical stream IDs through `BrokerService.NextStreamID`, which is
247-physical keys, and pending/live streams have explicit caps; retired IDs need no195+thread-safe, strictly monotone, never zero, and refuses an exhausted counter
248-unbounded tombstone collection. Coalesced connection attempts are independent196+instead of wrapping. An allocated ID is consumed even when the open it names is
249-of any single waiter and are canceled when their final reservation leaves.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.
250 Physical retirement keeps its key occupied until Close finishes. Fake-clock idle207 Physical retirement keeps its key occupied until Close finishes. Fake-clock idle
251 eviction and shutdown close transports outside the bookkeeping lock.208 eviction and shutdown close transports outside the bookkeeping lock.
252 209
@@ -305,7 +262,13 @@ a stopped one.
305 Attachment streams declare one closed admission variant262 Attachment streams declare one closed admission variant
306 (`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral263 (`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral
307 creation) validated identically at the ports, broker IPC, and daemonmux264 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.
309 272
310 ## Broker-owned local route (Plan 001 P5.3a, not activated)273 ## Broker-owned local route (Plan 001 P5.3a, not activated)
311 274
@@ -337,11 +300,55 @@ from the binding and never from durable membership, since the local daemon has
337 no membership; its observed identity, incarnation, protocol version,300 no membership; its observed identity, incarnation, protocol version,
338 availability, and session catalogue come only from the local probe. The local301 availability, and session catalogue come only from the local probe. The local
339 entry is never durable: the snapshot store persists and reloads remote302 entry is never durable: the snapshot store persists and reloads remote
340-observations only. Observing the local daemon completes only the daemonmux303+observations only. Observing the local daemon dials only an existing local Unix
341-physical preamble and closes the carriage, so it never opens a logical stream304+socket, so it never starts a stopped daemon; it completes the authenticated
342-(never attaches) and only ever dials an existing local Unix socket, so it never305+daemonmux physical preamble, opens exactly one `BrokerStreamObservation` logical
343-starts a stopped daemon. Nothing in `internal/app` or `main` activates this306+stream, sends one typed remote-catalog control request, validates the catalogue
344-composition for ordinary operation; P7 performs the coordinated cutover.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.
345 352
346 ## Broker local IPC (Plan 001 P3.3, not activated)353 ## Broker local IPC (Plan 001 P3.3, not activated)
347 354
@@ -351,9 +358,7 @@ per-user Unix endpoint. The carriage is the P3.2 private AF_UNIX carriage in
351 parent directory, a bound socket tightened to 0600, race-safe recovery of a358 parent directory, a bound socket tightened to 0600, race-safe recovery of a
352 stale socket whose owner died, refusal (never removal) of a path that is not a359 stale socket whose owner died, refusal (never removal) of a path that is not a
353 socket, and same-user kernel peer credentials (`SO_PEERCRED` on Linux) on both360 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. The361+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.
355-endpoint name (`broker.sock`) is deliberately distinct from the daemon's
356-`daemon.sock`.
357 362
358 The listener owns its accept loop: one client slot is acquired before each363 The listener owns its accept loop: one client slot is acquired before each
359 accept, each accepted carriage completes the broker preamble and broker364 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
528 layer never carries a target, a path, or an argv word.533 layer never carries a target, a path, or an argv word.
529 534
530 `_broker-serve` composes a transport-selecting `daemonmux.EndpointConnector`. Its535 `_broker-serve` composes a transport-selecting `daemonmux.EndpointConnector`. Its
531-dial function receives only the opaque address, looks the route up in the536+dial function receives the whole resolved `ports.BrokerDialTarget`, looks the
532-immutable configuration it was built with, and refuses an address the537+route up by its opaque address in the immutable configuration it was built
533-configuration did not produce. A `unix` route dials the provisioned private Unix538+with, refuses an address the configuration did not produce, and re-checks that
534-mux carriage directly; an `ssh-stdio` route starts one explicit ssh child (built539+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
535 with `sshstdio.BuildCommandForMux` and carrying only trust inputs that narrow546 with `sshstdio.BuildCommandForMux` and carrying only trust inputs that narrow
536 verification, never disabling host-key checking, forcing an auth bypass, or547 verification, never disabling host-key checking, forcing an auth bypass, or
537-requesting a PTY) through `sshstdio.DialMuxContext`; an `ssh-quic` route runs one548+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
538 SSH-authenticated QUIC bootstrap, reads exactly one readiness line, and pins the551 SSH-authenticated QUIC bootstrap, reads exactly one readiness line, and pins the
539 freshly minted ephemeral certificate through `quic.DialMuxContext`. One552 freshly minted ephemeral certificate through `quic.DialMuxContext`. One
540 caller-supplied setup context bounds bootstrap, pinned dial, and the daemonmux553 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,
551 a log, or an error.564 a log, or an error.
552 565
553 The three hidden helpers are excluded from public help. `_broker-mux-stdio566 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 single567+--offline-root ABS [--daemon-start existing-only|if-needed]` validates the
555-provisioned private Unix daemonmux carriage. `_broker-mux-quic-bootstrap568+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
556 --offline-root ABS` validates the sandbox, starts one detached574 --offline-root ABS` validates the sandbox, starts one detached
557 `_broker-mux-quic-proxy` in a new session, forwards its single readiness line,575 `_broker-mux-quic-proxy` in a new session, forwards its single readiness line,
558 and exits. `_broker-mux-quic-proxy --offline-root ABS` mints one ephemeral QUIC576 and exits. `_broker-mux-quic-proxy --offline-root ABS` mints one ephemeral QUIC
559 server, admits exactly one authenticated carriage, and bridges it to the same577 server, admits exactly one authenticated carriage, and bridges it to the same
560-private Unix daemonmux carriage. A helper only ever bridges to the configured578+private Unix daemonmux carriage under the same propagated authorization. A
561-private endpoint: it never starts a broker, an observer, or an ordinary daemon,579+helper only ever bridges to the configured
562-never dials the production daemon socket, and never fabricates a daemon580+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
563 incarnation, which the daemon on the far side of the carriage alone owns.583 incarnation, which the daemon on the far side of the carriage alone owns.
564 Ordinary production composition still waits for P7; the helpers change no584 Ordinary production composition still waits for P7; the helpers change no
565 ordinary command, path, or factory.585 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
7 web.listen = 127.0.0.1:87787 web.listen = 127.0.0.1:8778
8 web.origin = http://127.0.0.1:87788 web.origin = http://127.0.0.1:8778
9 9
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-
17 # Theme: auto follows the client; dark/light use neutral built-in defaults.10 # Theme: auto follows the client; dark/light use neutral built-in defaults.
18 theme = auto11 theme = auto
19 # In auto mode with palette inheritance enabled, infer a terminal accent.12 # In auto mode with palette inheritance enabled, infer a terminal accent.
@@ -95,38 +88,6 @@ code.detach = DET
95 88
96 Invalid values log a warning and resolve that setting to its default on both initial load and reload.89 Invalid values log a warning and resolve that setting to its default on both initial load and reload.
97 90
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-
130 ## Scrollback91 ## Scrollback
131 92
132 `vev` chooses the retention policy; `vev-vt` enforces it. The PTY transports93 `vev` chooses the retention policy; `vev-vt` enforces it. The PTY transports
M docs/ui-driver.md +63 -44
@@ -4,18 +4,66 @@
4 4
5 ## Headless attachment5 ## Headless attachment
6 6
7-Start a new numbered session with the default 80x24 viewport:7+Start a new numbered ephemeral session with the default 80x24 viewport:
8 8
9 ```sh9 ```sh
10 vev --ui-driver10 vev --ui-driver
11 ```11 ```
12 12
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:
14 39
15 ```json40 ```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"}}
17 ```42 ```
18 43
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+
19 The stream then accepts one JSON object per line. Every request has `version`, a nonzero `id`, `op`, and the discovered `attachment`:67 The stream then accepts one JSON object per line. Every request has `version`, a nonzero `id`, `op`, and the discovered `attachment`:
20 68
21 ```json69 ```json
@@ -52,7 +100,7 @@ A request timeout only ends that request. If input was already accepted, the res
52 {"version":1,"id":6,"op":"wait","attachment":"<opaque-handle>","after_action":4,"timeout_ms":5000,"expect":{"text_contains":"driver ok","status":"attached"}}100 {"version":1,"id":6,"op":"wait","attachment":"<opaque-handle>","after_action":4,"timeout_ms":5000,"expect":{"text_contains":"driver ok","status":"attached"}}
53 ```101 ```
54 102
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.
56 104
57 ## Limits and errors105 ## Limits and errors
58 106
@@ -66,49 +114,20 @@ Supported predicates are literal `text_contains`, an exact `session` (`lifecycle
66 114
67 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.115 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.
68 116
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.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.
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:
74 118
75-```sh119+## Isolation
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-```
108 120
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.
110 128
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.
112 131
113 ## Existing interactive clients132 ## Existing interactive clients
114 133
M internal/adapters/brokerconfig/config.go +34 -3
@@ -294,6 +294,31 @@ func Load(layout Layout) (*Config, error) {
294 // LoadPath parses the broker configuration at an explicit application-owned294 // LoadPath parses the broker configuration at an explicit application-owned
295 // path while retaining the route and reserved-path validation from layout.295 // path while retaining the route and reserved-path validation from layout.
296 func LoadPath(layout Layout, path string) (*Config, error) {296 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) {
297 raw, err := readConfig(path)322 raw, err := readConfig(path)
298 if err != nil {323 if err != nil {
299 return nil, err324 return nil, err
@@ -351,9 +376,15 @@ func LoadPath(layout Layout, path string) (*Config, error) {
351 }376 }
352 config.order = append(config.order, registration.Registration.Endpoint)377 config.order = append(config.order, registration.Registration.Endpoint)
353 }378 }
354- local, err := parseLocal(document.Local)379+ if authoritativeLocal != nil && document.Local != nil {
355- if err != nil {380+ return nil, fmt.Errorf("brokerconfig: %s: production local authority must not be configured", ConfigFileName)
356- return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err)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+ }
357 }388 }
358 if local != nil {389 if local != nil {
359 if err := rejectRouteOverlap(local.Route.Path(), layout.reserved); err != nil {390 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) {
120 require.NoError(t, err)120 require.NoError(t, err)
121 require.Equal(t, []string{testEndpoint}, config.Endpoints())121 require.Equal(t, []string{testEndpoint}, config.Endpoints())
122 122
123- resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{123+ resolved, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{
124 Purpose: ports.BrokerStreamControl,124 Purpose: ports.BrokerStreamControl,
125 Endpoint: testEndpoint,125 Endpoint: testEndpoint,
126 Registration: testRegistration(),126 Registration: testRegistration(),
127 Policy: testPolicy(),127 Policy: testPolicy(),
128+ StartMode: ports.BrokerDaemonExistingOnly,
128 })129 })
129 require.NoError(t, err)130 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)
131 require.Equal(t, testPolicy(), resolved.Policy)132 require.Equal(t, testPolicy(), resolved.Policy)
133+ require.Equal(t, ports.BrokerDaemonExistingOnly, resolved.StartMode, "resolution propagates the requested start mode")
132 134
133 local, err := config.LocalMuxRoute()135 local, err := config.LocalMuxRoute()
134 require.NoError(t, err)136 require.NoError(t, err)
@@ -484,14 +486,16 @@ func TestLoadAliasesShareOneRoute(t *testing.T) {
484 for _, endpoint := range []string{first, second} {486 for _, endpoint := range []string{first, second} {
485 registration := testRegistration()487 registration := testRegistration()
486 registration.Endpoint = endpoint488 registration.Endpoint = endpoint
487- resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{489+ resolved, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{
488 Purpose: ports.BrokerStreamControl,490 Purpose: ports.BrokerStreamControl,
489 Endpoint: endpoint,491 Endpoint: endpoint,
490 Registration: registration,492 Registration: registration,
491 Policy: testPolicy(),493 Policy: testPolicy(),
494+ StartMode: ports.BrokerDaemonStartIfNeeded,
492 })495 })
493 require.NoError(t, err)496 require.NoError(t, err)
494 require.Equal(t, local.Address(), resolved.Address)497 require.Equal(t, local.Address(), resolved.Address)
498+ require.Equal(t, ports.BrokerDaemonStartIfNeeded, resolved.StartMode)
495 }499 }
496 }500 }
497 501
@@ -535,12 +539,13 @@ func TestResolverRefusals(t *testing.T) {
535 Endpoint: testEndpoint,539 Endpoint: testEndpoint,
536 Registration: testRegistration(),540 Registration: testRegistration(),
537 Policy: testPolicy(),541 Policy: testPolicy(),
542+ StartMode: ports.BrokerDaemonExistingOnly,
538 }543 }
539 544
540 t.Run("unknown endpoint", func(t *testing.T) {545 t.Run("unknown endpoint", func(t *testing.T) {
541 request := base546 request := base
542 request.Endpoint = "other@daemon"547 request.Endpoint = "other@daemon"
543- _, err := resolver.Resolve(context.Background(), request)548+ _, err := resolver.ResolveDialTarget(context.Background(), request)
544 var typed ports.BrokerError549 var typed ports.BrokerError
545 require.ErrorAs(t, err, &typed)550 require.ErrorAs(t, err, &typed)
546 require.Equal(t, ports.BrokerErrorUnavailable, typed.Code)551 require.Equal(t, ports.BrokerErrorUnavailable, typed.Code)
@@ -549,7 +554,7 @@ func TestResolverRefusals(t *testing.T) {
549 t.Run("local request", func(t *testing.T) {554 t.Run("local request", func(t *testing.T) {
550 request := base555 request := base
551 request.Local = true556 request.Local = true
552- _, err := resolver.Resolve(context.Background(), request)557+ _, err := resolver.ResolveDialTarget(context.Background(), request)
553 var typed ports.BrokerError558 var typed ports.BrokerError
554 require.ErrorAs(t, err, &typed)559 require.ErrorAs(t, err, &typed)
555 require.Equal(t, ports.BrokerErrorUnavailable, typed.Code)560 require.Equal(t, ports.BrokerErrorUnavailable, typed.Code)
@@ -558,7 +563,7 @@ func TestResolverRefusals(t *testing.T) {
558 t.Run("stale registration", func(t *testing.T) {563 t.Run("stale registration", func(t *testing.T) {
559 request := base564 request := base
560 request.Registration.Generation = 2565 request.Registration.Generation = 2
561- _, err := resolver.Resolve(context.Background(), request)566+ _, err := resolver.ResolveDialTarget(context.Background(), request)
562 var typed ports.BrokerError567 var typed ports.BrokerError
563 require.ErrorAs(t, err, &typed)568 require.ErrorAs(t, err, &typed)
564 require.Equal(t, ports.BrokerErrorUnavailable, typed.Code)569 require.Equal(t, ports.BrokerErrorUnavailable, typed.Code)
@@ -567,7 +572,7 @@ func TestResolverRefusals(t *testing.T) {
567 t.Run("conflicting policy", func(t *testing.T) {572 t.Run("conflicting policy", func(t *testing.T) {
568 request := base573 request := base
569 request.Policy.Transport = "other-transport"574 request.Policy.Transport = "other-transport"
570- _, err := resolver.Resolve(context.Background(), request)575+ _, err := resolver.ResolveDialTarget(context.Background(), request)
571 var typed ports.BrokerError576 var typed ports.BrokerError
572 require.ErrorAs(t, err, &typed)577 require.ErrorAs(t, err, &typed)
573 require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code)578 require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code)
@@ -575,7 +580,7 @@ func TestResolverRefusals(t *testing.T) {
575 t.Run("cancelled context", func(t *testing.T) {580 t.Run("cancelled context", func(t *testing.T) {
576 ctx, cancel := context.WithCancel(context.Background())581 ctx, cancel := context.WithCancel(context.Background())
577 cancel()582 cancel()
578- _, err := resolver.Resolve(ctx, base)583+ _, err := resolver.ResolveDialTarget(ctx, base)
579 require.ErrorIs(t, err, context.Canceled)584 require.ErrorIs(t, err, context.Canceled)
580 })585 })
581 }586 }
M internal/adapters/brokerconfig/local_test.go +8 -7
@@ -57,9 +57,10 @@ func loadDocument(t *testing.T, document any, reserved []string) (*Config, error
57 // localRequest builds one local open-stream request under the fixture policy.57 // localRequest builds one local open-stream request under the fixture policy.
58 func localRequest() ports.BrokerOpenStreamRequest {58 func localRequest() ports.BrokerOpenStreamRequest {
59 return ports.BrokerOpenStreamRequest{59 return ports.BrokerOpenStreamRequest{
60- Purpose: ports.BrokerStreamControl,60+ Purpose: ports.BrokerStreamControl,
61- Local: true,61+ Local: true,
62- Policy: testPolicy(),62+ Policy: testPolicy(),
63+ StartMode: ports.BrokerDaemonStartIfNeeded,
63 }64 }
64 }65 }
65 66
@@ -78,9 +79,9 @@ func TestLoadLocalBinding(t *testing.T) {
78 require.True(t, binding.Route.IsLocal())79 require.True(t, binding.Route.IsLocal())
79 require.Equal(t, localTestSocket, binding.Route.Path())80 require.Equal(t, localTestSocket, binding.Route.Path())
80 81
81- resolved, err := config.Resolver().Resolve(context.Background(), localRequest())82+ resolved, err := config.Resolver().ResolveDialTarget(context.Background(), localRequest())
82 require.NoError(t, err)83 require.NoError(t, err)
83- require.Equal(t, binding.Identity, resolved.Identity)84+ require.Equal(t, binding.Identity, resolved.ExpectedIdentity.Identity)
84 require.Equal(t, binding.Policy, resolved.Policy)85 require.Equal(t, binding.Policy, resolved.Policy)
85 require.Equal(t, binding.Route.Address(), resolved.Address)86 require.Equal(t, binding.Route.Address(), resolved.Address)
86 87
@@ -276,10 +277,10 @@ func TestResolveLocalSuccessAndRefusals(t *testing.T) {
276 cancel()277 cancel()
277 ctx = cancelled278 ctx = cancelled
278 }279 }
279- resolved, err := tt.resolver.Resolve(ctx, tt.request)280+ resolved, err := tt.resolver.ResolveDialTarget(ctx, tt.request)
280 if tt.wantErr == nil {281 if tt.wantErr == nil {
281 require.NoError(t, err)282 require.NoError(t, err)
282- require.Equal(t, binding.Identity, resolved.Identity)283+ require.Equal(t, binding.Identity, resolved.ExpectedIdentity.Identity)
283 require.Equal(t, binding.Policy, resolved.Policy)284 require.Equal(t, binding.Policy, resolved.Policy)
284 require.Equal(t, binding.Route.Address(), resolved.Address)285 require.Equal(t, binding.Route.Address(), resolved.Address)
285 return286 return
M internal/adapters/brokerconfig/resolver.go +22 -14
@@ -7,7 +7,7 @@ import (
7 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
8 )8 )
9 9
10-// Resolver is the immutable ports.BrokerEndpointResolver over one parsed10+// Resolver is the immutable ports.BrokerRouteAuthority over one parsed
11 // offline configuration. It holds no mutable state and performs no I/O: every11 // offline configuration. It holds no mutable state and performs no I/O: every
12 // Resolve call fences the request against the provisioned registration and12 // Resolve call fences the request against the provisioned registration and
13 // returns the configured identity, policy, and opaque route address. A request13 // returns the configured identity, policy, and opaque route address. A request
@@ -24,7 +24,7 @@ type Resolver struct {
24 local *LocalBinding24 local *LocalBinding
25 }25 }
26 26
27-var _ ports.BrokerEndpointResolver = (*Resolver)(nil)27+var _ ports.BrokerRouteAuthority = (*Resolver)(nil)
28 28
29 // Resolve fences one open-stream request against the immutable configuration.29 // Resolve fences one open-stream request against the immutable configuration.
30 //30 //
@@ -40,26 +40,30 @@ var _ ports.BrokerEndpointResolver = (*Resolver)(nil)
40 // Unknown, stale, conflicting, or unprovisioned requests are refused with a40 // Unknown, stale, conflicting, or unprovisioned requests are refused with a
41 // typed broker error and no dial is attempted; a cancelled context is reported41 // typed broker error and no dial is attempted; a cancelled context is reported
42 // as such so the pool classifies it as cancellation rather than unavailability.42 // 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) {
44 if err := ctx.Err(); err != nil {44 if err := ctx.Err(); err != nil {
45- return ports.BrokerResolvedEndpoint{}, err45+ return ports.BrokerDialTarget{}, err
46 }46 }
47 if request.Local {47 if request.Local {
48 return r.resolveLocal(request)48 return r.resolveLocal(request)
49 }49 }
50 provisioned, ok := r.byEndpoint[request.Endpoint]50 provisioned, ok := r.byEndpoint[request.Endpoint]
51 if !ok {51 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}
53 }53 }
54 if !request.Registration.Equal(provisioned.Registration) {54 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}
56 }56 }
57 if !provisioned.Policy.Compatible(request.Policy) {57 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 != ""},
59 }64 }
60- resolved := ports.BrokerResolvedEndpoint{Identity: provisioned.Identity, Policy: provisioned.Policy, Address: provisioned.Route.Address()}
61 if err := resolved.Validate(); err != nil {65 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}
63 }67 }
64 return resolved, nil68 return resolved, nil
65 }69 }
@@ -70,16 +74,20 @@ func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRe
70 // provisioned local policy is refused as a conflicting policy. The resolved74 // provisioned local policy is refused as a conflicting policy. The resolved
71 // identity, policy, and address are the binding's own, so the request supplies75 // identity, policy, and address are the binding's own, so the request supplies
72 // none of them.76 // 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) {
74 if r.local == nil {78 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}
76 }80 }
77 if !r.local.Policy.Compatible(request.Policy) {81 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 != ""},
79 }88 }
80- resolved := ports.BrokerResolvedEndpoint{Identity: r.local.Identity, Policy: r.local.Policy, Address: r.local.Route.Address()}
81 if err := resolved.Validate(); err != nil {89 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}
83 }91 }
84 return resolved, nil92 return resolved, nil
85 }93 }
M internal/adapters/brokerconfig/route.go +13 -0
@@ -15,6 +15,7 @@ import (
15 "unicode/utf8"15 "unicode/utf8"
16 16
17 "github.com/bnema/vev/internal/domain"17 "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/ports"
18 )19 )
19 20
20 // Bounded route variants (Plan 001 P3.4 slice E).21 // Bounded route variants (Plan 001 P3.4 slice E).
@@ -317,6 +318,18 @@ func parseTrust(document *trustDocument) (TrustInputs, error) {
317 return trust, nil318 return trust, nil
318 }319 }
319 320
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+
320 func newUnixRoute(path string) (Route, error) {333 func newUnixRoute(path string) (Route, error) {
321 route := Route{kind: RouteUnix, path: path, local: true}334 route := Route{kind: RouteUnix, path: path, local: true}
322 if err := route.Validate(); err != nil {335 if err := route.Validate(); err != nil {
M internal/adapters/brokeripc/client.go +54 -29
@@ -216,14 +216,16 @@ type client struct {
216 err error216 err error
217 sub *clientSubscription217 sub *clientSubscription
218 generation brokerwire.SubscriptionGeneration218 generation brokerwire.SubscriptionGeneration
219- highest ports.BrokerStreamID
220 nextStream ports.BrokerStreamID219 nextStream ports.BrokerStreamID
221 snapshot ports.BrokerSnapshot220 snapshot ports.BrokerSnapshot
222 pending map[ports.BrokerOperationID]chan operationResult221 pending map[ports.BrokerOperationID]chan operationResult
223 kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind222 kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind
224- streams map[ports.BrokerStreamID]*clientStream223+ // The connection's own stream tracker is the anti-replay authority for
225- closeOnce sync.Once224+ // this connection: it admits every identity at most once inside the bounded
226- done chan struct{}225+ // window, whether the open is afterwards accepted or refused.
226+ streams map[ports.BrokerStreamID]*clientStream
227+ closeOnce sync.Once
228+ done chan struct{}
227 }229 }
228 230
229 var _ ports.BrokerService = (*client)(nil)231 var _ ports.BrokerService = (*client)(nil)
@@ -349,11 +351,15 @@ func (c *client) dispatch(message brokerwire.ServerMessage) error {
349 }351 }
350 cause := failureFromDetail(m.Error)352 cause := failureFromDetail(m.Error)
351 if cause == nil {353 if cause == nil {
352- cause = ports.BrokerError{Code: ports.BrokerErrorUnavailable}354+ cause = ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: "empty broker error frame"}
353 }355 }
354 return cause356 return cause
355 case brokerwire.Shutdown:357 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}
357 default:363 default:
358 return errors.Join(ErrProtocol, errors.New("brokeripc: unexpected server message"))364 return errors.Join(ErrProtocol, errors.New("brokeripc: unexpected server message"))
359 }365 }
@@ -403,6 +409,27 @@ func (c *client) completeOperation(operation ports.BrokerOperationID, result ope
403 // ConnectionID returns the identity assigned to this connection at accept.409 // ConnectionID returns the identity assigned to this connection at accept.
404 func (c *client) ConnectionID() ports.BrokerConnectionID { return c.scope.Connection }410 func (c *client) ConnectionID() ports.BrokerConnectionID { return c.scope.Connection }
405 411
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+
406 // Done closes exactly once when this connection is terminal: the carriage433 // Done closes exactly once when this connection is terminal: the carriage
407 // failed, the broker retired it, or this side closed it. Err is stable434 // failed, the broker retired it, or this side closed it. Err is stable
408 // afterwards.435 // afterwards.
@@ -476,9 +503,13 @@ func (c *client) Subscribe() (ports.BrokerSubscription, error) {
476 }503 }
477 504
478 // OpenStream opens one independently cancellable logical stream. An absent505 // OpenStream opens one independently cancellable logical stream. An absent
479-// epoch, connection, or stream identity is filled from this connection; a506+// epoch or connection identity is filled from this connection and a
480-// caller-supplied identity that belongs elsewhere, or a stream identity that is507+// caller-supplied identity that belongs elsewhere is refused as stale. The
481-// not strictly increasing, is refused as stale.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.
482 func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {513 func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
483 scoped, err := c.scopeRequest(request)514 scoped, err := c.scopeRequest(request)
484 if err != nil {515 if err != nil {
@@ -491,7 +522,10 @@ func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamR
491 return nil, err522 return nil, err
492 }523 }
493 // Admit the identity on the connection's own tracker before any frame524 // 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.
495 if err := c.conn.OpenStream(scoped.Stream); err != nil {529 if err := c.conn.OpenStream(scoped.Stream); err != nil {
496 c.mu.Unlock()530 c.mu.Unlock()
497 return nil, admissionError(err)531 return nil, admissionError(err)
@@ -514,6 +548,7 @@ func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamR
514 Local: scoped.Local,548 Local: scoped.Local,
515 Endpoint: scoped.Endpoint, Registration: scoped.Registration,549 Endpoint: scoped.Endpoint, Registration: scoped.Registration,
516 Target: scoped.Target, Env: scoped.Env, Policy: scoped.Policy,550 Target: scoped.Target, Env: scoped.Env, Policy: scoped.Policy,
551+ StartMode: scoped.StartMode,
517 }); err != nil {552 }); err != nil {
518 c.retireStream(scoped.Stream, err)553 c.retireStream(scoped.Stream, err)
519 return nil, err554 return nil, err
@@ -615,8 +650,12 @@ func (c *client) Close() error {
615 return nil650 return nil
616 }651 }
617 652
618-// scopeRequest fills an absent identity from this connection and refuses one653+// scopeRequest fills an absent epoch or connection identity from this
619-// that belongs elsewhere, then validates the resulting request.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.
620 func (c *client) scopeRequest(request ports.BrokerOpenStreamRequest) (ports.BrokerOpenStreamRequest, error) {659 func (c *client) scopeRequest(request ports.BrokerOpenStreamRequest) (ports.BrokerOpenStreamRequest, error) {
621 scoped := request660 scoped := request
622 if scoped.Epoch == 0 {661 if scoped.Epoch == 0 {
@@ -629,23 +668,9 @@ func (c *client) scopeRequest(request ports.BrokerOpenStreamRequest) (ports.Brok
629 } else if scoped.Connection != c.scope.Connection {668 } else if scoped.Connection != c.scope.Connection {
630 return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale)669 return ports.BrokerOpenStreamRequest{}, errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale)
631 }670 }
632- c.mu.Lock()671+ if scoped.Stream == 0 {
633- switch {672+ return ports.BrokerOpenStreamRequest{}, errors.Join(ports.BrokerAdmissionInvalid, errors.New("brokeripc: open stream request has no stream identity"))
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
647 }673 }
648- c.mu.Unlock()
649 if err := scoped.Validate(); err != nil {674 if err := scoped.Validate(); err != nil {
650 return ports.BrokerOpenStreamRequest{}, errors.Join(ports.BrokerAdmissionInvalid, err)675 return ports.BrokerOpenStreamRequest{}, errors.Join(ports.BrokerAdmissionInvalid, err)
651 }676 }
@@ -742,7 +767,7 @@ func (c *client) sendAsync(message brokerwire.ClientMessage) error {
742 if async, ok := c.transport.(wire.AsyncTransport); ok {767 if async, ok := c.transport.(wire.AsyncTransport); ok {
743 if err := async.SendAsync(wire.Envelope{Payload: payload}); err != nil {768 if err := async.SendAsync(wire.Envelope{Payload: payload}); err != nil {
744 if errors.Is(err, streamframe.ErrBackpressure) {769 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"}
746 }771 }
747 return transportFailure(err)772 return transportFailure(err)
748 }773 }
M internal/adapters/brokeripc/errors.go +10 -1
@@ -131,10 +131,19 @@ func errorDetail(err error) brokerwire.ErrorDetail {
131 case errors.Is(err, context.Canceled):131 case errors.Is(err, context.Canceled):
132 return brokerwire.ErrorDetail{Code: ports.BrokerErrorCancelled}132 return brokerwire.ErrorDetail{Code: ports.BrokerErrorCancelled}
133 default:133 default:
134- return brokerwire.ErrorDetail{Code: ports.BrokerErrorUnavailable}134+ return brokerwire.ErrorDetail{Code: ports.BrokerErrorUnavailable, Text: boundedBrokerErrorText(err)}
135 }135 }
136 }136 }
137 137
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+
138 // failureFromDetail converts one received detail back into the typed local147 // failureFromDetail converts one received detail back into the typed local
139 // failure. A detail with no code is the absent-detail case and yields nil.148 // failure. A detail with no code is the absent-detail case and yields nil.
140 func failureFromDetail(detail brokerwire.ErrorDetail) error {149 func failureFromDetail(detail brokerwire.ErrorDetail) error {
M internal/adapters/brokeripc/helpers_test.go +44 -0
@@ -46,6 +46,39 @@ func testPolicy() ports.BrokerPolicy {
46 }46 }
47 }47 }
48 48
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+
49 // membershipProbeEndpoint is the endpoint every liveness probe adds through the82 // membershipProbeEndpoint is the endpoint every liveness probe adds through the
50 // real membership delegation path.83 // real membership delegation path.
51 const membershipProbeEndpoint = "probe@host:22"84 const membershipProbeEndpoint = "probe@host:22"
@@ -367,10 +400,21 @@ type fakeCore struct {
367 addResult *domain.RemoteRegistration400 addResult *domain.RemoteRegistration
368 updateResult *domain.RemoteRegistration401 updateResult *domain.RemoteRegistration
369 removeRemoved bool402 removeRemoved bool
403+
404+ nextStream ports.BrokerStreamID
370 }405 }
371 406
372 func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id }407 func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id }
373 408
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+
374 func (c *fakeCore) Done() <-chan struct{} { return c.done }418 func (c *fakeCore) Done() <-chan struct{} { return c.done }
375 419
376 func (c *fakeCore) Err() error { return nil }420 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) {
258 require.ErrorIs(t, admissionError(brokerwire.ErrFutureStream), ports.BrokerAdmissionInvalid)258 require.ErrorIs(t, admissionError(brokerwire.ErrFutureStream), ports.BrokerAdmissionInvalid)
259 }259 }
260 260
261-// TestScopeRequestFillsIdentity proves an absent identity is filled from the261+// TestScopeRequestFillsIdentity proves an absent epoch/connection identity is
262-// connection, a foreign one is refused as stale, and stream identities advance262+// filled from the connection, a foreign one is refused as stale, and a request
263-// strictly.263+// without a stream identity is refused as invalid rather than allocated here.
264 func TestScopeRequestFillsIdentity(t *testing.T) {264 func TestScopeRequestFillsIdentity(t *testing.T) {
265 c := &client{265 c := &client{
266 scope: brokerwire.Scope{Epoch: 4, Connection: ports.BrokerConnectionID{4}},266 scope: brokerwire.Scope{Epoch: 4, Connection: ports.BrokerConnectionID{4}},
@@ -271,28 +271,33 @@ func TestScopeRequestFillsIdentity(t *testing.T) {
271 cfg: Config{}.withDefaults(),271 cfg: Config{}.withDefaults(),
272 transport: nil,272 transport: nil,
273 }273 }
274- scoped, err := c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()})274+ scoped, err := c.scopeRequest(openRequest(1))
275 require.NoError(t, err)275 require.NoError(t, err)
276 require.Equal(t, ports.BrokerEpoch(4), scoped.Epoch)276 require.Equal(t, ports.BrokerEpoch(4), scoped.Epoch)
277 require.Equal(t, ports.BrokerConnectionID{4}, scoped.Connection)277 require.Equal(t, ports.BrokerConnectionID{4}, scoped.Connection)
278 require.Equal(t, ports.BrokerStreamID(1), scoped.Stream)278 require.Equal(t, ports.BrokerStreamID(1), scoped.Stream)
279 279
280 _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{280 _, 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,
282 Connection: ports.BrokerConnectionID{0x11},282 Connection: ports.BrokerConnectionID{0x11},
283 })283 })
284 require.ErrorIs(t, err, ports.BrokerAdmissionStale)284 require.ErrorIs(t, err, ports.BrokerAdmissionStale)
285 285
286 _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{286 _, 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,
288 })288 })
289 require.ErrorIs(t, err, ErrScopeMismatch)289 require.ErrorIs(t, err, ErrScopeMismatch)
290 290
291- scoped, err = c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 5, Policy: testPolicy()})291+ scoped, err = c.scopeRequest(openRequest(5))
292 require.NoError(t, err)292 require.NoError(t, err)
293 require.Equal(t, ports.BrokerStreamID(5), scoped.Stream)293 require.Equal(t, ports.BrokerStreamID(5), scoped.Stream)
294- _, err = c.scopeRequest(ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 5, Policy: testPolicy()})294+
295- require.ErrorIs(t, err, ports.BrokerAdmissionStale)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)
296 }301 }
297 302
298 // TestSocketPaths proves the endpoint name is distinct from the daemon socket303 // 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) {
158 raw.send(t, brokerwire.OpenStream{158 raw.send(t, brokerwire.OpenStream{
159 Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1,159 Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1,
160 Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),160 Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
161+ StartMode: ports.BrokerDaemonStartIfNeeded,
161 })162 })
162 opened, ok := raw.recv(t).(brokerwire.StreamOpened)163 opened, ok := raw.recv(t).(brokerwire.StreamOpened)
163 require.True(t, ok, "the stream must be established before chunk enforcement matters")164 require.True(t, ok, "the stream must be established before chunk enforcement matters")
@@ -186,6 +187,7 @@ func TestScopeMismatchOpenStreamIsRefusedAsStale(t *testing.T) {
186 raw.send(t, brokerwire.OpenStream{187 raw.send(t, brokerwire.OpenStream{
187 Epoch: scope.Epoch, Connection: ports.BrokerConnectionID{0x7f},188 Epoch: scope.Epoch, Connection: ports.BrokerConnectionID{0x7f},
188 Stream: 1, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),189 Stream: 1, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
190+ StartMode: ports.BrokerDaemonStartIfNeeded,
189 })191 })
190 closed, ok := raw.recv(t).(brokerwire.StreamClosed)192 closed, ok := raw.recv(t).(brokerwire.StreamClosed)
191 require.True(t, ok, "a mismatched scope must be answered with StreamClosed")193 require.True(t, ok, "a mismatched scope must be answered with StreamClosed")
@@ -196,6 +198,7 @@ func TestScopeMismatchOpenStreamIsRefusedAsStale(t *testing.T) {
196 raw.send(t, brokerwire.OpenStream{198 raw.send(t, brokerwire.OpenStream{
197 Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1,199 Epoch: scope.Epoch, Connection: scope.Connection, Stream: 1,
198 Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),200 Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy(),
201+ StartMode: ports.BrokerDaemonStartIfNeeded,
199 })202 })
200 _, ok = raw.recv(t).(brokerwire.StreamOpened)203 _, ok = raw.recv(t).(brokerwire.StreamOpened)
201 require.True(t, ok)204 require.True(t, ok)
M internal/adapters/brokeripc/server.go +16 -1
@@ -899,6 +899,18 @@ func orderlyDisconnect(err error) bool {
899 // ConnectionID returns the identity assigned to this connection at accept.899 // ConnectionID returns the identity assigned to this connection at accept.
900 func (s *serverSession) ConnectionID() ports.BrokerConnectionID { return s.scope.Connection }900 func (s *serverSession) ConnectionID() ports.BrokerConnectionID { return s.scope.Connection }
901 901
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+
902 // Done closes exactly once when this session is terminal: the carriage failed,914 // Done closes exactly once when this session is terminal: the carriage failed,
903 // the peer disconnected, or the owner closed it. Err is stable afterwards.915 // the peer disconnected, or the owner closed it. Err is stable afterwards.
904 func (s *serverSession) Done() <-chan struct{} { return s.done }916 func (s *serverSession) Done() <-chan struct{} { return s.done }
@@ -929,7 +941,10 @@ func (s *serverSession) Subscribe() (ports.BrokerSubscription, error) { return s
929 // service. The connection's own reader drives the same core through941 // service. The connection's own reader drives the same core through
930 // openStreamMessage, which additionally bridges the stream onto the wire; this942 // openStreamMessage, which additionally bridges the stream onto the wire; this
931 // method exists so a composed caller can use the session as a scoped943 // 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.
933 func (s *serverSession) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {948 func (s *serverSession) OpenStream(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
934 if err := s.checkRequestScope(request.Epoch, request.Connection); err != nil {949 if err := s.checkRequestScope(request.Epoch, request.Connection); err != nil {
935 return nil, err950 return nil, err
M internal/adapters/brokeripc/session_test.go +66 -30
@@ -109,9 +109,7 @@ func TestSessionCloseStreamFillsConnectionScope(t *testing.T) {
109 109
110 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)110 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
111 defer cancel()111 defer cancel()
112- opened, err := session.OpenStream(ctx, ports.BrokerOpenStreamRequest{112+ opened, err := session.OpenStream(ctx, openRequest(1))
113- Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy(),
114- })
115 require.NoError(t, err)113 require.NoError(t, err)
116 require.NotNil(t, opened)114 require.NotNil(t, opened)
117 115
@@ -143,10 +141,7 @@ func TestOpenStreamAdmissionRoundTrips(t *testing.T) {
143 141
144 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)142 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
145 defer cancel()143 defer cancel()
146- stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{144+ stream, err := client.OpenStream(ctx, attachmentRequest(1, ports.BrokerAdmissionCreateNamed, "work"))
147- Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed, Name: "work",
148- Local: true, Policy: testPolicy(),
149- })
150 require.NoError(t, err)145 require.NoError(t, err)
151 require.NotNil(t, stream)146 require.NotNil(t, stream)
152 147
@@ -157,6 +152,42 @@ func TestOpenStreamAdmissionRoundTrips(t *testing.T) {
157 require.Equal(t, "work", core.opens[0].Name)152 require.Equal(t, "work", core.opens[0].Name)
158 require.Equal(t, ports.BrokerStreamAttachment, core.opens[0].Purpose)153 require.Equal(t, ports.BrokerStreamAttachment, core.opens[0].Purpose)
159 require.True(t, core.opens[0].Local)154 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)
160 }191 }
161 192
162 // TestRegistrationAssignsScopeAtAccept proves the client adapter adopts exactly193 // TestRegistrationAssignsScopeAtAccept proves the client adapter adopts exactly
@@ -304,11 +335,7 @@ func TestOpenStreamCarriesTypedTraffic(t *testing.T) {
304 335
305 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)336 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
306 defer cancel()337 defer cancel()
307- stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{338+ stream, err := client.OpenStream(ctx, openRequest(1))
308- Purpose: ports.BrokerStreamControl,
309- Local: true,
310- Policy: testPolicy(),
311- })
312 require.NoError(t, err)339 require.NoError(t, err)
313 t.Cleanup(func() { _ = stream.Close() })340 t.Cleanup(func() { _ = stream.Close() })
314 341
@@ -351,11 +378,7 @@ func TestOpenStreamRefusedByCoreReachesClient(t *testing.T) {
351 378
352 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)379 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
353 defer cancel()380 defer cancel()
354- stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{381+ stream, err := client.OpenStream(ctx, openRequest(1))
355- Purpose: ports.BrokerStreamControl,
356- Local: true,
357- Policy: testPolicy(),
358- })
359 require.Error(t, err)382 require.Error(t, err)
360 require.Nil(t, stream)383 require.Nil(t, stream)
361 var failure ports.BrokerError384 var failure ports.BrokerError
@@ -379,9 +402,9 @@ func TestTerminalStreamFailureFailsOnlyThatStream(t *testing.T) {
379 402
380 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)403 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
381 defer cancel()404 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)))
383 require.NoError(t, err)406 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)))
385 require.NoError(t, err)408 require.NoError(t, err)
386 t.Cleanup(func() { _ = first.Close(); _ = second.Close() })409 t.Cleanup(func() { _ = first.Close(); _ = second.Close() })
387 410
@@ -419,7 +442,7 @@ func TestCloseStreamRetiresBothSides(t *testing.T) {
419 442
420 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)443 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
421 defer cancel()444 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)))
423 require.NoError(t, err)446 require.NoError(t, err)
424 require.NoError(t, client.CloseStream(client.ConnectionID(), 1))447 require.NoError(t, client.CloseStream(client.ConnectionID(), 1))
425 448
@@ -705,7 +728,7 @@ func TestDisconnectCleanupReleasesEveryOwnedResource(t *testing.T) {
705 728
706 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)729 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
707 defer cancel()730 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)))
709 require.NoError(t, err)732 require.NoError(t, err)
710 conn := core.logicalConn(1)733 conn := core.logicalConn(1)
711 require.NotNil(t, conn)734 require.NotNil(t, conn)
@@ -767,7 +790,7 @@ func TestClientStreamBackpressureSettlesOnlyThatStream(t *testing.T) {
767 790
768 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)791 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
769 defer cancel()792 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)))
771 require.NoError(t, err)794 require.NoError(t, err)
772 conn := core.logicalConn(1)795 conn := core.logicalConn(1)
773 require.NotNil(t, conn)796 require.NotNil(t, conn)
@@ -802,7 +825,7 @@ func TestOrderlyStreamCloseReportsNoError(t *testing.T) {
802 825
803 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)826 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
804 defer cancel()827 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)))
806 require.NoError(t, err)829 require.NoError(t, err)
807 require.NoError(t, stream.Close())830 require.NoError(t, stream.Close())
808 select {831 select {
@@ -825,23 +848,36 @@ func TestScopeIdentityFencing(t *testing.T) {
825 defer cancel()848 defer cancel()
826 foreign := ports.BrokerConnectionID{0x99}849 foreign := ports.BrokerConnectionID{0x99}
827 _, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{850 _, 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,
829 })852 })
830 require.ErrorIs(t, err, ports.BrokerAdmissionStale)853 require.ErrorIs(t, err, ports.BrokerAdmissionStale)
831 854
832 _, err = client.OpenStream(ctx, ports.BrokerOpenStreamRequest{855 _, 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,
834 })857 })
835 require.ErrorIs(t, err, ErrScopeMismatch)858 require.ErrorIs(t, err, ErrScopeMismatch)
836 var stale ports.BrokerError859 var stale ports.BrokerError
837 require.ErrorAs(t, err, &stale)860 require.ErrorAs(t, err, &stale)
838 require.Equal(t, ports.BrokerErrorStaleEpoch, stale.Code)861 require.Equal(t, ports.BrokerErrorStaleEpoch, stale.Code)
839 862
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))
841 require.NoError(t, err)866 require.NoError(t, err)
842 t.Cleanup(func() { _ = first.Close() })867 t.Cleanup(func() { _ = first.Close() })
843- _, err = client.OpenStream(ctx, ports.BrokerOpenStreamRequest{Purpose: ports.BrokerStreamControl, Local: true, Stream: 1, Policy: testPolicy()})868+ second, err := client.OpenStream(ctx, openRequest(1))
844- require.ErrorIs(t, err, ports.BrokerAdmissionStale)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")
845 }881 }
846 882
847 // TestRequestReconcileIsBoundedHint proves the reconcile hint is sent only for an883 // TestRequestReconcileIsBoundedHint proves the reconcile hint is sent only for an
@@ -881,7 +917,7 @@ func TestCoreErrorSurfacesAsTypedFailure(t *testing.T) {
881 917
882 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)918 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
883 defer cancel()919 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)))
885 require.NoError(t, err)921 require.NoError(t, err)
886 t.Cleanup(func() { _ = stream.Close() })922 t.Cleanup(func() { _ = stream.Close() })
887 923
@@ -906,7 +942,7 @@ func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) {
906 942
907 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)943 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
908 defer cancel()944 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)))
910 require.NoError(t, err)946 require.NoError(t, err)
911 conn := core.logicalConn(1)947 conn := core.logicalConn(1)
912 require.NotNil(t, conn)948 require.NotNil(t, conn)
M internal/adapters/brokeripc/stream.go +1 -0
@@ -208,6 +208,7 @@ func (s *serverSession) startStream(m brokerwire.OpenStream) {
208 Connection: m.Connection, Stream: m.Stream,208 Connection: m.Connection, Stream: m.Stream,
209 Endpoint: m.Endpoint, Registration: m.Registration,209 Endpoint: m.Endpoint, Registration: m.Registration,
210 Target: m.Target, Env: m.Env, Policy: m.Policy,210 Target: m.Target, Env: m.Env, Policy: m.Policy,
211+ StartMode: m.StartMode,
211 }212 }
212 s.wg.Add(1)213 s.wg.Add(1)
213 go func() {214 go func() {
M internal/adapters/brokerstore/legacy.go +12 -3
@@ -110,12 +110,17 @@ func migrate(o Options) (recovery, error) {
110 if r.Cache, err = source(o.LegacyCache); err != nil {110 if r.Cache, err = source(o.LegacyCache); err != nil {
111 return r, err111 return r, err
112 }112 }
113- r.State.Manifest = manifest{Version: 1, Sources: []manifestSource{113+ r.State.Manifest = manifest{Version: 1, ImportVersion: 1, ImportCompleted: true, Sources: []manifestSource{
114 {Label: manifestMembership, Present: o.LegacyHosts != "", SHA256: digest(r.Hosts)},114 {Label: manifestMembership, Present: o.LegacyHosts != "", SHA256: digest(r.Hosts)},
115 {Label: manifestObservations, Present: o.LegacyCache != "", SHA256: digest(r.Cache)},115 {Label: manifestObservations, Present: o.LegacyCache != "", SHA256: digest(r.Cache)},
116 }}116 }}
117 r.State.Hosts = ports.BrokerHosts{Revision: 1}117 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 != "" {
119 r.State.Hosts.Hosts, err = decodeHosts(r.Hosts, o.Policies)124 r.State.Hosts.Hosts, err = decodeHosts(r.Hosts, o.Policies)
120 if err != nil {125 if err != nil {
121 return r, err126 return r, err
@@ -273,7 +278,11 @@ func decodeHosts(raw []byte, policies map[string]ports.BrokerPolicy) ([]ports.Br
273 return nil, err278 return nil, err
274 }279 }
275 indices[record.Endpoint] = len(out)280 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})
277 }286 }
278 }287 }
279 return out, nil288 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 (
13 "fmt"13 "fmt"
14 "os"14 "os"
15 "path/filepath"15 "path/filepath"
16+ "reflect"
16 "sync"17 "sync"
17 "syscall"18 "syscall"
18 19
@@ -68,8 +69,10 @@ type manifestSource struct {
68 }69 }
69 70
70 type manifest struct {71 type manifest struct {
71- Version int72+ Version int
72- Sources []manifestSource73+ Sources []manifestSource
74+ ImportVersion int
75+ ImportCompleted bool
73 }76 }
74 77
75 // source returns the manifest entry for one generic role label.78 // source returns the manifest entry for one generic role label.
@@ -88,6 +91,8 @@ func (m manifest) source(label manifestSourceLabel) (manifestSource, bool) {
88 type Options struct {91 type Options struct {
89 Dir, LegacyHosts, LegacyCache string92 Dir, LegacyHosts, LegacyCache string
90 Policies map[string]ports.BrokerPolicy93 Policies map[string]ports.BrokerPolicy
94+ InitialHosts []ports.BrokerHostRecord
95+ InitialImportProvided bool
91 Fault func(string) error96 Fault func(string) error
92 }97 }
93 98
@@ -183,7 +188,23 @@ func Open(o Options) (*Store, error) {
183 if err != nil {188 if err != nil {
184 return nil, err189 return nil, err
185 }190 }
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 {
187 return nil, err208 return nil, err
188 }209 }
189 ok = true210 ok = true
@@ -240,7 +261,7 @@ func (s *Store) LoadHosts() (ports.BrokerHosts, error) {
240 return ports.BrokerHosts{}, err261 return ports.BrokerHosts{}, err
241 }262 }
242 h := s.state.Hosts263 h := s.state.Hosts
243- h.Hosts = append([]ports.BrokerHostRecord(nil), h.Hosts...)264+ h.Hosts = ports.CloneBrokerHostRecords(h.Hosts)
244 return h, nil265 return h, nil
245 }266 }
246 func (s *Store) Load() (ports.BrokerSnapshot, error) {267 func (s *Store) Load() (ports.BrokerSnapshot, error) {
@@ -267,7 +288,7 @@ func (s *Store) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) er
267 return ErrStale288 return ErrStale
268 }289 }
269 next := s.state290 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)}
271 // Policy changes require a new registration generation as well; otherwise292 // Policy changes require a new registration generation as well; otherwise
272 // an already queued observation could be accepted under changed trust.293 // an already queued observation could be accepted under changed trust.
273 for _, h := range hosts {294 for _, h := range hosts {
@@ -350,6 +371,9 @@ func validate(st state) error {
350 if st.Manifest.Version != 1 {371 if st.Manifest.Version != 1 {
351 return invalid("unsupported manifest")372 return invalid("unsupported manifest")
352 }373 }
374+ if st.Manifest.ImportVersion != 1 || !st.Manifest.ImportCompleted {
375+ return invalid("initial import is incomplete")
376+ }
353 labels := make(map[manifestSourceLabel]bool, len(st.Manifest.Sources))377 labels := make(map[manifestSourceLabel]bool, len(st.Manifest.Sources))
354 for _, source := range st.Manifest.Sources {378 for _, source := range st.Manifest.Sources {
355 if labels[source.Label] {379 if labels[source.Label] {
M internal/adapters/brokerwire/codec.go +38 -1
@@ -499,6 +499,7 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
499 Connection: m.Connection, Stream: m.Stream,499 Connection: m.Connection, Stream: m.Stream,
500 Endpoint: m.Endpoint, Registration: m.Registration,500 Endpoint: m.Endpoint, Registration: m.Registration,
501 Target: m.Target, Env: m.Env, Policy: m.Policy,501 Target: m.Target, Env: m.Env, Policy: m.Policy,
502+ StartMode: m.StartMode,
502 }503 }
503 if err := request.Validate(); err != nil {504 if err := request.Validate(); err != nil {
504 // Distinguish bound refusals from semantic refusals.505 // Distinguish bound refusals from semantic refusals.
@@ -530,6 +531,10 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
530 if err != nil {531 if err != nil {
531 return nil, err532 return nil, err
532 }533 }
534+ startMode, err := startModeToWire(m.StartMode)
535+ if err != nil {
536+ return nil, err
537+ }
533 out := &wire.OpenStream{538 out := &wire.OpenStream{
534 Ref: refToWire(m.Epoch, m.Connection, m.Stream),539 Ref: refToWire(m.Epoch, m.Connection, m.Stream),
535 Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint,540 Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint,
@@ -538,7 +543,7 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
538 // The admission taxonomy mirrors the port values (0 none, 1 exact,543 // The admission taxonomy mirrors the port values (0 none, 1 exact,
539 // 2 create named, 3 create ephemeral); the name travels only for the544 // 2 create named, 3 create ephemeral); the name travels only for the
540 // create-named variant.545 // create-named variant.
541- Admission: admission, Name: m.Name,546+ Admission: admission, Name: m.Name, StartMode: startMode,
542 }547 }
543 if !m.Local {548 if !m.Local {
544 out.Registration = registrationToWire(m.Registration)549 out.Registration = registrationToWire(m.Registration)
@@ -584,6 +589,32 @@ func admissionFromWire(value uint32) (ports.BrokerStreamAdmission, error) {
584 }589 }
585 }590 }
586 591
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+
587 func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {618 func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
588 var out OpenStream619 var out OpenStream
589 if message == nil {620 if message == nil {
@@ -608,6 +639,10 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
608 if err != nil {639 if err != nil {
609 return OpenStream{}, ErrInvalidMessage640 return OpenStream{}, ErrInvalidMessage
610 }641 }
642+ startMode, err := startModeFromWire(message.GetStartMode())
643+ if err != nil {
644+ return OpenStream{}, ErrInvalidMessage
645+ }
611 local := message.GetLocal()646 local := message.GetLocal()
612 var registration domain.RemoteRegistration647 var registration domain.RemoteRegistration
613 if !local {648 if !local {
@@ -647,6 +682,7 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
647 Local: local,682 Local: local,
648 Endpoint: endpoint,683 Endpoint: endpoint,
649 Registration: registration, Target: target, Env: env, Policy: policy,684 Registration: registration, Target: target, Env: env, Policy: policy,
685+ StartMode: startMode,
650 }686 }
651 request := ports.BrokerOpenStreamRequest{687 request := ports.BrokerOpenStreamRequest{
652 Epoch: candidate.Epoch, Purpose: candidate.Purpose,688 Epoch: candidate.Epoch, Purpose: candidate.Purpose,
@@ -654,6 +690,7 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
654 Connection: candidate.Connection, Stream: candidate.Stream,690 Connection: candidate.Connection, Stream: candidate.Stream,
655 Endpoint: candidate.Endpoint, Registration: candidate.Registration,691 Endpoint: candidate.Endpoint, Registration: candidate.Registration,
656 Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,692 Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,
693+ StartMode: candidate.StartMode,
657 }694 }
658 if err := request.Validate(); err != nil {695 if err := request.Validate(); err != nil {
659 return OpenStream{}, ErrInvalidMessage696 return OpenStream{}, ErrInvalidMessage
M internal/adapters/brokerwire/codec_test.go +74 -8
@@ -354,7 +354,7 @@ func TestBrokerClientVariants(t *testing.T) {
354 {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, 105},354 {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, 105},
355 {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, 106},355 {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, 106},
356 {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107},356 {"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},
358 {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109},358 {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109},
359 {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110},359 {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110},
360 {"update_host_policy", UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration(), Policy: testPolicy()}, 111},360 {"update_host_policy", UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration(), Policy: testPolicy()}, 111},
@@ -384,6 +384,9 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
384 return OpenStream{384 return OpenStream{
385 Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment,385 Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment,
386 Endpoint: "dev@host:22", Registration: testRegistration(), Policy: testPolicy(),386 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,
387 }390 }
388 }391 }
389 roundTrips := []struct {392 roundTrips := []struct {
@@ -409,7 +412,7 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
409 }()},412 }()},
410 {"control carries none", OpenStream{413 {"control carries none", OpenStream{
411 Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl,414 Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl,
412- Local: true, Policy: testPolicy(),415+ Local: true, Policy: testPolicy(), StartMode: ports.BrokerDaemonStartIfNeeded,
413 }},416 }},
414 }417 }
415 for _, tc := range roundTrips {418 for _, tc := range roundTrips {
@@ -455,7 +458,17 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
455 }(),458 }(),
456 "control with admission": OpenStream{459 "control with admission": OpenStream{
457 Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl,460 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,
459 },472 },
460 }473 }
461 for name, message := range cases {474 for name, message := range cases {
@@ -480,6 +493,59 @@ func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
480 _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)493 _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
481 require.ErrorIs(t, err, ErrInvalidMessage)494 require.ErrorIs(t, err, ErrInvalidMessage)
482 })495 })
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+ })
483 }549 }
484 550
485 // TestBrokerSnapshotDaemonByteForByte proves the daemon and session parts are551 // TestBrokerSnapshotDaemonByteForByte proves the daemon and session parts are
@@ -949,7 +1015,7 @@ func TestBrokerValidationNegatives(t *testing.T) {
949 require.ErrorIs(t, err, ErrInvalidMessage)1015 require.ErrorIs(t, err, ErrInvalidMessage)
950 })1016 })
951 t.Run("taxonomy switches", func(t *testing.T) {1017 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})
953 envelope := &wire.BrokerClientEnvelope{}1019 envelope := &wire.BrokerClientEnvelope{}
954 require.NoError(t, wire.ScanEnvelope(envelope, raw))1020 require.NoError(t, wire.ScanEnvelope(envelope, raw))
955 require.NoError(t, proto.Unmarshal(raw, envelope))1021 require.NoError(t, proto.Unmarshal(raw, envelope))
@@ -981,11 +1047,11 @@ func TestBrokerValidationNegatives(t *testing.T) {
981 require.ErrorIs(t, err, ErrInvalidMessage)1047 require.ErrorIs(t, err, ErrInvalidMessage)
982 })1048 })
983 t.Run("env without equals refused", func(t *testing.T) {1049 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)
985 require.ErrorIs(t, err, ErrInvalidMessage)1051 require.ErrorIs(t, err, ErrInvalidMessage)
986 })1052 })
987 t.Run("invalid policy refused", func(t *testing.T) {1053 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)
989 require.ErrorIs(t, err, ErrInvalidMessage)1055 require.ErrorIs(t, err, ErrInvalidMessage)
990 })1056 })
991 t.Run("snapshot revision zero refused", func(t *testing.T) {1057 t.Run("snapshot revision zero refused", func(t *testing.T) {
@@ -1075,7 +1141,7 @@ func TestBrokerCorruptInputSmoke(t *testing.T) {
1075 mustEncodeClient(t, AddHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x21), Endpoint: "dev@host:22", Policy: testPolicy()}),1141 mustEncodeClient(t, AddHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x21), Endpoint: "dev@host:22", Policy: testPolicy()}),
1076 mustEncodeClient(t, RemoveHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x22), Registration: testRegistration()}),1142 mustEncodeClient(t, RemoveHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x22), Registration: testRegistration()}),
1077 mustEncodeClient(t, UpdateHostPolicy{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x23), Registration: testRegistration(), Policy: testPolicy()}),1143 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}),
1079 mustEncodeServer(t, SnapshotPart{Epoch: 7, Connection: testConnectionID(2), Generation: 1, Revision: 1, Index: 0, Part: SnapshotEnd{}}),1145 mustEncodeServer(t, SnapshotPart{Epoch: 7, Connection: testConnectionID(2), Generation: 1, Revision: 1, Index: 0, Part: SnapshotEnd{}}),
1080 mustEncodeServer(t, OperationResult{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x24), Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true}),1146 mustEncodeServer(t, OperationResult{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x24), Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true}),
1081 mustEncodeServer(t, Shutdown{Epoch: 7, Connection: testConnectionID(2), Reason: ShutdownIdleExit}),1147 mustEncodeServer(t, Shutdown{Epoch: 7, Connection: testConnectionID(2), Reason: ShutdownIdleExit}),
@@ -1108,7 +1174,7 @@ func fuzzClientSeeds(t testing.TB) [][]byte {
1108 AddHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x11), Endpoint: "dev@host:22", Policy: testPolicy()},1174 AddHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x11), Endpoint: "dev@host:22", Policy: testPolicy()},
1109 RemoveHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x12), Registration: testRegistration()},1175 RemoveHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x12), Registration: testRegistration()},
1110 UpdateHostPolicy{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x13), Registration: testRegistration(), Policy: testPolicy()},1176 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},
1112 ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")},1178 ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")},
1113 CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3},1179 CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3},
1114 } {1180 } {
M internal/adapters/brokerwire/messages.go +4 -1
@@ -122,7 +122,9 @@ func (Reconcile) brokerClientMessage() {}
122 // stream to the owning daemon. Env is the per-request session environment122 // stream to the owning daemon. Env is the per-request session environment
123 // and is never inherited from the broker process environment. Admission123 // and is never inherited from the broker process environment. Admission
124 // selects the attachment admission variant; Name is the validated session124 // 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.
126 type OpenStream struct {128 type OpenStream struct {
127 Epoch ports.BrokerEpoch129 Epoch ports.BrokerEpoch
128 Connection ports.BrokerConnectionID130 Connection ports.BrokerConnectionID
@@ -136,6 +138,7 @@ type OpenStream struct {
136 Target protocol.ExactSessionTarget138 Target protocol.ExactSessionTarget
137 Env []string139 Env []string
138 Policy ports.BrokerPolicy140 Policy ports.BrokerPolicy
141+ StartMode ports.BrokerDaemonStartMode
139 }142 }
140 143
141 func (OpenStream) brokerClientMessage() {}144 func (OpenStream) brokerClientMessage() {}
M internal/adapters/brokerwire/state.go +27 -12
@@ -81,9 +81,9 @@ var (
81 ErrUnknownOperation = errors.New("brokerwire: unknown operation")81 ErrUnknownOperation = errors.New("brokerwire: unknown operation")
82 // ErrInvalidStream reports a zero stream identity.82 // ErrInvalidStream reports a zero stream identity.
83 ErrInvalidStream = errors.New("brokerwire: invalid broker stream")83 ErrInvalidStream = errors.New("brokerwire: invalid broker stream")
84- // ErrStreamIDReused reports a stream identity that is not strictly above84+ // ErrStreamIDReused reports a stream identity that is already consumed or
85- // every allocated identity: stream IDs strictly increase and are never85+ // evicted outside the bounded anti-replay window. Stream identities are
86- // reused.86+ // allocated by the calling service and are never reused.
87 ErrStreamIDReused = errors.New("brokerwire: stream ID is not fresh")87 ErrStreamIDReused = errors.New("brokerwire: stream ID is not fresh")
88 // ErrFutureStream reports a frame for a stream identity that was never88 // ErrFutureStream reports a frame for a stream identity that was never
89 // allocated.89 // allocated.
@@ -718,17 +718,22 @@ const (
718 )718 )
719 719
720 // StreamTracker bounds the logical streams of one connection scope. Stream720 // StreamTracker bounds the logical streams of one connection scope. Stream
721-// identities strictly increase and are never reused. At most MaxStreams721+// identities are allocated by the calling service and may arrive slightly out
722-// streams are opening or open at once; at most MaxRetiredStreams retired722+// of order when two opens race, so admission uses one bounded anti-replay
723-// records are retained, so an older late frame is still classified as723+// window instead of a bare high-water mark: every unconsumed identity inside
724-// retired rather than mistaken for a future stream. It is safe for724+// the window is admitted exactly once, a duplicate is refused, and an identity
725-// concurrent use.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.
726 type StreamTracker struct {730 type StreamTracker struct {
727 mu sync.Mutex731 mu sync.Mutex
728 scope Scope732 scope Scope
729 states map[ports.BrokerStreamID]StreamState733 states map[ports.BrokerStreamID]StreamState
730 retired []ports.BrokerStreamID734 retired []ports.BrokerStreamID
731 highest ports.BrokerStreamID735 highest ports.BrokerStreamID
736+ window ports.BrokerStreamWindow
732 active int737 active int
733 }738 }
734 739
@@ -743,8 +748,12 @@ func newStreamTracker(scope Scope) (*StreamTracker, error) {
743 return &StreamTracker{scope: scope, states: make(map[ports.BrokerStreamID]StreamState, MaxStreams)}, nil748 return &StreamTracker{scope: scope, states: make(map[ports.BrokerStreamID]StreamState, MaxStreams)}, nil
744 }749 }
745 750
746-// Open allocates one fresh logical stream in Opening state. The identity must751+// Open allocates one fresh logical stream in Opening state. The identity is
747-// be strictly above every allocated identity and must not already be in use.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.
748 func (t *StreamTracker) Open(scope Scope, stream ports.BrokerStreamID) error {757 func (t *StreamTracker) Open(scope Scope, stream ports.BrokerStreamID) error {
749 if t == nil {758 if t == nil {
750 return ErrConnectionClosed759 return ErrConnectionClosed
@@ -757,14 +766,19 @@ func (t *StreamTracker) Open(scope Scope, stream ports.BrokerStreamID) error {
757 if err := stream.Validate(); err != nil {766 if err := stream.Validate(); err != nil {
758 return ErrInvalidStream767 return ErrInvalidStream
759 }768 }
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.
761 return ErrStreamIDReused773 return ErrStreamIDReused
762 }774 }
763 if t.active >= MaxStreams {775 if t.active >= MaxStreams {
764 return ErrTooManyStreams776 return ErrTooManyStreams
765 }777 }
766 t.states[stream] = StreamOpening778 t.states[stream] = StreamOpening
767- t.highest = stream779+ if stream > t.highest {
780+ t.highest = stream
781+ }
768 t.active++782 t.active++
769 return nil783 return nil
770 }784 }
@@ -909,5 +923,6 @@ func (t *StreamTracker) reset() {
909 t.states = make(map[ports.BrokerStreamID]StreamState, MaxStreams)923 t.states = make(map[ports.BrokerStreamID]StreamState, MaxStreams)
910 t.retired = nil924 t.retired = nil
911 t.highest = 0925 t.highest = 0
926+ t.window = ports.BrokerStreamWindow{}
912 t.active = 0927 t.active = 0
913 }928 }
M internal/adapters/brokerwire/state_test.go +18 -4
@@ -674,8 +674,9 @@ type streamStep struct {
674 wantErr error674 wantErr error
675 }675 }
676 676
677-// TestStreamTrackerOpenRules proves stream identities strictly increase, are677+// TestStreamTrackerOpenRules proves a stream identity is admitted at most once
678-// never reused, and that at most MaxStreams streams are concurrent.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.
679 func TestStreamTrackerOpenRules(t *testing.T) {680 func TestStreamTrackerOpenRules(t *testing.T) {
680 scope := testBrokerScope()681 scope := testBrokerScope()
681 682
@@ -685,12 +686,15 @@ func TestStreamTrackerOpenRules(t *testing.T) {
685 require.ErrorIs(t, tr.Open(scope, 0), ErrInvalidStream)686 require.ErrorIs(t, tr.Open(scope, 0), ErrInvalidStream)
686 })687 })
687 688
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) {
689 tr, err := newStreamTracker(scope)690 tr, err := newStreamTracker(scope)
690 require.NoError(t, err)691 require.NoError(t, err)
691 require.NoError(t, tr.Open(scope, 5))692 require.NoError(t, tr.Open(scope, 5))
692 require.ErrorIs(t, tr.Open(scope, 5), ErrStreamIDReused)693 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))
694 require.NoError(t, tr.Opened(scope, 5))698 require.NoError(t, tr.Opened(scope, 5))
695 require.NoError(t, tr.Open(scope, 6))699 require.NoError(t, tr.Open(scope, 6))
696 if disposition, err := tr.Close(scope, 5); err != nil || disposition != StreamAccepted {700 if disposition, err := tr.Close(scope, 5); err != nil || disposition != StreamAccepted {
@@ -698,9 +702,19 @@ func TestStreamTrackerOpenRules(t *testing.T) {
698 }702 }
699 require.ErrorIs(t, tr.Open(scope, 5), ErrStreamIDReused)703 require.ErrorIs(t, tr.Open(scope, 5), ErrStreamIDReused)
700 require.ErrorIs(t, tr.Open(scope, 6), ErrStreamIDReused)704 require.ErrorIs(t, tr.Open(scope, 6), ErrStreamIDReused)
705+ require.ErrorIs(t, tr.Open(scope, 1), ErrStreamIDReused)
701 require.NoError(t, tr.Open(scope, 7))706 require.NoError(t, tr.Open(scope, 7))
702 })707 })
703 708
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+
704 t.Run("concurrent ceiling", func(t *testing.T) {718 t.Run("concurrent ceiling", func(t *testing.T) {
705 tr, err := newStreamTracker(scope)719 tr, err := newStreamTracker(scope)
706 require.NoError(t, err)720 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) {
37 seenTabsKeys := make(map[string]bool)37 seenTabsKeys := make(map[string]bool)
38 seenScrollbackKeys := make(map[string]bool)38 seenScrollbackKeys := make(map[string]bool)
39 seenWebKeys := make(map[string]bool)39 seenWebKeys := make(map[string]bool)
40- seenRemoteKeys := make(map[string]bool)
41 40
42 scanner := bufio.NewScanner(r)41 scanner := bufio.NewScanner(r)
43 lineNo := 042 lineNo := 0
@@ -65,34 +64,6 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
65 }64 }
66 65
67 switch {66 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
96 case key == "web.listen" || key == "web.origin":67 case key == "web.listen" || key == "web.origin":
97 warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo)68 warnings = warnDuplicateKey(warnings, seenWebKeys, key, lineNo)
98 // Keep raw values for fail-closed validation at gateway startup.69 // 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) {
359 tt.want.Palette = domain.Defaults().Palette359 tt.want.Palette = domain.Defaults().Palette
360 }360 }
361 tt.want.Scrollback = domain.DefaultScrollbackConfig()361 tt.want.Scrollback = domain.DefaultScrollbackConfig()
362- tt.want.AttachmentCache = domain.DefaultAttachmentCacheConfig()
363 got, warnings, err := Parse(strings.NewReader(tt.input))362 got, warnings, err := Parse(strings.NewReader(tt.input))
364 if err != nil {363 if err != nil {
365 t.Fatalf("Parse() error = %v", err)364 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) {
27 Connection: ports.BrokerConnectionID{1},27 Connection: ports.BrokerConnectionID{1},
28 Stream: ports.BrokerStreamID(1),28 Stream: ports.BrokerStreamID(1),
29 Policy: logicalTestPolicy(),29 Policy: logicalTestPolicy(),
30+ StartMode: ports.BrokerDaemonStartIfNeeded,
30 }31 }
31 target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}32 target := protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha"}
32 33
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 @@
1 package daemonmux1 package daemonmux
2 2
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-
25 import (3 import (
26 "errors"4 "errors"
27 5
28 "github.com/bnema/vev/internal/ports"6 "github.com/bnema/vev/internal/ports"
29 )7 )
30 8
31-// ErrInvalidBinding reports a daemon-side physical binding that is not9+// ErrInvalidBinding reports an invalid daemon authority. An authority must
32-// authoritative: an invalid or empty authenticated daemon identity, a zero10+// contain a valid identity, a non-zero incarnation, and a non-empty closed set
33-// daemon incarnation, or an invalid connection policy. NewServerBinding11+// of distinct, valid provisioned admissions.
34-// refuses it once, at construction, so the handshake never re-validates an12+var ErrInvalidBinding = errors.New("daemonmux: invalid server bindings")
35-// unusable binding.13+
36-var ErrInvalidBinding = errors.New("daemonmux: invalid server binding")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+}
37 38
38-// ServerBinding is the daemon's validated, immutable physical binding for one39+// ServerBinding is an immutable daemon authority. The entry slice is cloned at
39-// accepted daemonmux connection. Its fields are unexported and reachable only40+// construction and is never exposed, making the admitted set safe to share
40-// through the Identity, Incarnation, and Policy accessors, so a ServerBinding41+// between concurrent handshakes.
41-// value is immutable once constructed and safe to copy and share.
42 type ServerBinding struct {42 type ServerBinding struct {
43 identity ports.BrokerDaemonIdentity43 identity ports.BrokerDaemonIdentity
44 incarnation ports.BrokerDaemonIncarnation44 incarnation ports.BrokerDaemonIncarnation
45- policy ports.BrokerPolicy45+ entries []ServerPolicyAdmission
46 }46 }
47 47
48-// NewServerBinding validates and returns the daemon's immutable physical48+// NewServerBindings constructs a closed admission authority from provisioned
49-// binding. It refuses an invalid authenticated identity, a zero incarnation,49+// entries. An empty set, a duplicate policy, an invalid policy, and an unknown
50-// or an invalid policy with ErrInvalidBinding; the returned binding either50+// origin are rejected. Entries are cloned, so a later change to the caller's
51-// validates completely or is never handed out.51+// slice cannot weaken what the handshake already enforced.
52-func NewServerBinding(identity ports.BrokerDaemonIdentity, incarnation ports.BrokerDaemonIncarnation, policy ports.BrokerPolicy) (ServerBinding, error) {52+func NewServerBindings(identity ports.BrokerDaemonIdentity, incarnation ports.BrokerDaemonIncarnation, entries []ServerPolicyAdmission) (ServerBinding, error) {
53- binding := ServerBinding{identity: identity, incarnation: incarnation, policy: policy}53+ binding := ServerBinding{identity: identity, incarnation: incarnation, entries: append([]ServerPolicyAdmission(nil), entries...)}
54 if err := binding.Validate(); err != nil {54 if err := binding.Validate(); err != nil {
55 return ServerBinding{}, err55 return ServerBinding{}, err
56 }56 }
57 return binding, nil57 return binding, nil
58 }58 }
59 59
60-// Identity returns the stable authenticated daemon identity the binding60+// NewServerBinding is retained as a source-compatible shorthand for fixtures
61-// advertises and pools on.61+// that provision exactly one local policy. It is never used by production
62-func (b ServerBinding) Identity() ports.BrokerDaemonIdentity { return b.identity }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+}
63 66
64-// Incarnation returns the nonzero process incarnation the binding advertises67+func (b ServerBinding) Identity() ports.BrokerDaemonIdentity { return b.identity }
65-// so a broker can detect a restart behind the stable identity.
66 func (b ServerBinding) Incarnation() ports.BrokerDaemonIncarnation { return b.incarnation }68 func (b ServerBinding) Incarnation() ports.BrokerDaemonIncarnation { return b.incarnation }
67 69
68-// Policy returns the exact connection policy the binding requires.70+// Policy returns the sole entry's policy for legacy single-policy callers. It
69-func (b ServerBinding) Policy() ports.BrokerPolicy { return b.policy }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+}
70 78
71-// Validate reports whether the binding is authoritative: a valid authenticated79+// Origin returns the sole entry's locality for legacy single-policy callers. It
72-// identity, a nonzero incarnation, and a valid policy.80+// returns the unknown origin for a multi-entry authority.
73-func (b ServerBinding) Validate() error {81+func (b ServerBinding) Origin() ports.SessionConnectionOrigin {
74- if err := b.identity.Validate(); err != nil {82+ if len(b.entries) == 1 {
75- return ErrInvalidBinding83+ return b.entries[0].Origin
76 }84 }
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 {
78 return ErrInvalidBinding90 return ErrInvalidBinding
79 }91 }
80- if err := b.policy.Validate(); err != nil {92+ for i, entry := range b.entries {
81- return ErrInvalidBinding93+ 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+ }
82 }104 }
83 return nil105 return nil
84 }106 }
85 107
86-// Accepts reports whether the requested policy exactly equals the binding's108+// Accepted returns the exact provisioned entry matching policy, carrying that
87-// policy. Pooling requires exact equality across every field, so a request109+// entry's policy and origin. No merging, inheritance, echo, or fallback is
88-// that differs in any field is refused rather than merged or inherited.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+
89 func (b ServerBinding) Accepts(policy ports.BrokerPolicy) bool {120 func (b ServerBinding) Accepts(policy ports.BrokerPolicy) bool {
90- return b.policy.Compatible(policy)121+ _, ok := b.Accepted(policy)
122+ return ok
91 }123 }
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) {
273 Epoch: m.Ref.Epoch, Purpose: m.Purpose, Admission: m.Admission, Name: m.Name, Local: m.Local,273 Epoch: m.Ref.Epoch, Purpose: m.Purpose, Admission: m.Admission, Name: m.Name, Local: m.Local,
274 Connection: m.Ref.Connection, Stream: m.Ref.Client,274 Connection: m.Ref.Connection, Stream: m.Ref.Client,
275 Endpoint: m.Endpoint, Registration: m.Registration,275 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,
277 }277 }
278 if err := request.Validate(); err != nil {278 if err := request.Validate(); err != nil {
279 // Distinguish bound refusals from semantic refusals.279 // Distinguish bound refusals from semantic refusals.
@@ -294,11 +294,19 @@ func openToWire(m Open) (*wire.MuxOpen, error) {
294 if err != nil {294 if err != nil {
295 return nil, err295 return nil, err
296 }296 }
297+ startMode, err := startModeToWire(m.StartMode)
298+ if err != nil {
299+ return nil, err
300+ }
297 out := &wire.MuxOpen{301 out := &wire.MuxOpen{
298 Ref: refToWire(m.Ref),302 Ref: refToWire(m.Ref),
299 Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint,303 Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint,
300 Env: append([]string(nil), m.Env...),304 Env: append([]string(nil), m.Env...),
301 Policy: policyToWire(m.Policy),305 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,
302 }310 }
303 if !m.Local {311 if !m.Local {
304 out.Registration = registrationToWire(m.Registration)312 out.Registration = registrationToWire(m.Registration)
@@ -336,6 +344,10 @@ func openFromWire(message *wire.MuxOpen) (Open, error) {
336 if purpose != ports.BrokerStreamAttachment && (admission != 0 || name != "") {344 if purpose != ports.BrokerStreamAttachment && (admission != 0 || name != "") {
337 return Open{}, ErrInvalidMessage345 return Open{}, ErrInvalidMessage
338 }346 }
347+ startMode, err := startModeFromWire(message.GetStartMode())
348+ if err != nil {
349+ return Open{}, ErrInvalidMessage
350+ }
339 local := message.GetLocal()351 local := message.GetLocal()
340 var registration domain.RemoteRegistration352 var registration domain.RemoteRegistration
341 if !local {353 if !local {
@@ -374,12 +386,14 @@ func openFromWire(message *wire.MuxOpen) (Open, error) {
374 candidate := Open{386 candidate := Open{
375 Ref: ref, Purpose: purpose, Admission: admission, Name: name, Local: local, Endpoint: endpoint,387 Ref: ref, Purpose: purpose, Admission: admission, Name: name, Local: local, Endpoint: endpoint,
376 Registration: registration, Target: target, Env: env, Policy: policy,388 Registration: registration, Target: target, Env: env, Policy: policy,
389+ StartMode: startMode,
377 }390 }
378 request := ports.BrokerOpenStreamRequest{391 request := ports.BrokerOpenStreamRequest{
379 Epoch: candidate.Ref.Epoch, Purpose: candidate.Purpose, Admission: candidate.Admission, Name: candidate.Name, Local: candidate.Local,392 Epoch: candidate.Ref.Epoch, Purpose: candidate.Purpose, Admission: candidate.Admission, Name: candidate.Name, Local: candidate.Local,
380 Connection: candidate.Ref.Connection, Stream: candidate.Ref.Client,393 Connection: candidate.Ref.Connection, Stream: candidate.Ref.Client,
381 Endpoint: candidate.Endpoint, Registration: candidate.Registration,394 Endpoint: candidate.Endpoint, Registration: candidate.Registration,
382 Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,395 Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,
396+ StartMode: candidate.StartMode,
383 }397 }
384 if err := request.Validate(); err != nil {398 if err := request.Validate(); err != nil {
385 return Open{}, ErrInvalidMessage399 return Open{}, ErrInvalidMessage
M internal/adapters/daemonmux/codec_test.go +76 -4
@@ -43,6 +43,18 @@ func testPolicy() ports.BrokerPolicy {
43 }43 }
44 }44 }
45 45
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+
46 func testTarget() protocol.ExactSessionTarget {58 func testTarget() protocol.ExactSessionTarget {
47 return protocol.ExactSessionTarget{59 return protocol.ExactSessionTarget{
48 LifecycleID: domain.SessionLifecycleID{1, 2, 3},60 LifecycleID: domain.SessionLifecycleID{1, 2, 3},
@@ -97,6 +109,7 @@ func testOpen() Open {
97 Ref: testRef(5), Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Local: false,109 Ref: testRef(5), Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Local: false,
98 Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(),110 Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(),
99 Env: []string{"TERM=xterm-256color"}, Policy: testPolicy(),111 Env: []string{"TERM=xterm-256color"}, Policy: testPolicy(),
112+ StartMode: ports.BrokerDaemonStartIfNeeded,
100 }113 }
101 }114 }
102 115
@@ -371,7 +384,7 @@ func TestMuxValidationNegatives(t *testing.T) {
371 _, err = EncodeServer(Opened{Ref: zeroClient}, testEnvelopeCeiling, testChunkCeiling)384 _, err = EncodeServer(Opened{Ref: zeroClient}, testEnvelopeCeiling, testChunkCeiling)
372 require.ErrorIs(t, err, ErrInvalidMessage)385 require.ErrorIs(t, err, ErrInvalidMessage)
373 386
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)
375 require.ErrorIs(t, err, ErrInvalidMessage)388 require.ErrorIs(t, err, ErrInvalidMessage)
376 })389 })
377 t.Run("open taxonomy and shape", func(t *testing.T) {390 t.Run("open taxonomy and shape", func(t *testing.T) {
@@ -412,7 +425,7 @@ func TestMuxValidationNegatives(t *testing.T) {
412 require.ErrorIs(t, err, ErrInvalidMessage)425 require.ErrorIs(t, err, ErrInvalidMessage)
413 426
414 // A local control open with no attachment state is legal.427 // 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}
416 raw, err := EncodeClient(valid, testEnvelopeCeiling, testChunkCeiling)429 raw, err := EncodeClient(valid, testEnvelopeCeiling, testChunkCeiling)
417 require.NoError(t, err)430 require.NoError(t, err)
418 decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)431 decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
@@ -694,7 +707,7 @@ func TestMuxAdmissionVariants(t *testing.T) {
694 require.Equal(t, ephemeral, roundTrip(t, ephemeral))707 require.Equal(t, ephemeral, roundTrip(t, ephemeral))
695 })708 })
696 t.Run("control never carries admission", func(t *testing.T) {709 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}
698 require.Equal(t, control, roundTrip(t, control))711 require.Equal(t, control, roundTrip(t, control))
699 mutated := mustEncodeClient(t, control)712 mutated := mustEncodeClient(t, control)
700 envelope := &wire.MuxClientEnvelope{}713 envelope := &wire.MuxClientEnvelope{}
@@ -731,13 +744,72 @@ func TestMuxAdmissionVariants(t *testing.T) {
731 })744 })
732 }745 }
733 746
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+
734 // TestMuxAdmissionWireRange proves the MuxOpen decoder narrows the wire806 // TestMuxAdmissionWireRange proves the MuxOpen decoder narrows the wire
735 // admission field before any cast: the accepted semantic set (no admission, or807 // admission field before any cast: the accepted semantic set (no admission, or
736 // one of the three closed admission codes) is unchanged, while every wire value808 // one of the three closed admission codes) is unchanged, while every wire value
737 // whose high bits would alias a valid code is refused instead of silently809 // whose high bits would alias a valid code is refused instead of silently
738 // truncating onto that code.810 // truncating onto that code.
739 func TestMuxAdmissionWireRange(t *testing.T) {811 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}
741 named := testOpen()813 named := testOpen()
742 named.Admission = ports.BrokerAdmissionCreateNamed814 named.Admission = ports.BrokerAdmissionCreateNamed
743 named.Name = "work"815 named.Name = "work"
M internal/adapters/daemonmux/connector.go +20 -10
@@ -49,11 +49,18 @@ import (
49 var ErrConnectorConfig = errors.New("daemonmux: invalid endpoint connector configuration")49 var ErrConnectorConfig = errors.New("daemonmux: invalid endpoint connector configuration")
50 50
51 // RawCarrierDialer establishes one already authenticated raw framed carriage51 // RawCarrierDialer establishes one already authenticated raw framed carriage
52-// for an opaque endpoint address. It returns a carriage the daemonmux52+// for one resolved dial target. It returns a carriage the daemonmux
53 // FramedCarrierBridge can adapt; it returns promptly once ctx is done. The53 // FramedCarrierBridge can adapt; it returns promptly once ctx is done. The
54 // carriage's authentication and transport selection live entirely outside this54 // carriage's authentication and transport selection live entirely outside this
55 // package, so the connector carries no dial policy of its own.55 // 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)
57 64
58 // EndpointConnector implements ports.BrokerEndpointConnector over daemonmux. It65 // EndpointConnector implements ports.BrokerEndpointConnector over daemonmux. It
59 // owns no carriage state itself: every Connect produces one independent66 // owns no carriage state itself: every Connect produces one independent
@@ -83,12 +90,15 @@ func NewEndpointConnector(dial RawCarrierDialer, ceilings MuxCeilings) (*Endpoin
83 90
84 // Connect dials the resolved endpoint, completes the daemonmux physical91 // Connect dials the resolved endpoint, completes the daemonmux physical
85 // preamble against the endpoint's authoritative identity and policy, and92 // preamble against the endpoint's authoritative identity and policy, and
86-// returns a live ports.BrokerPhysicalConnection. The caller's ctx (or its93+// returns a live ports.BrokerPhysicalConnection. The resolved target is handed
87-// deadline) bounds dial and handshake together; every failure closes the94+// to the dial function whole, after validation, so the carriage owner sees the
88-// carriage, so no half-open physical connection survives. A successful95+// exact start authorization and fence the connector is about to verify. The
89-// connection is detached from ctx: cancelling or expiring ctx afterwards never96+// caller's ctx (or its deadline) bounds dial and handshake together; every
90-// stops the pooled physical connection, which Close alone owns.97+// failure closes the carriage, so no half-open physical connection survives. A
91-func (c *EndpointConnector) Connect(ctx context.Context, endpoint ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) {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) {
92 if c == nil || c.dial == nil {102 if c == nil || c.dial == nil {
93 return nil, ErrConnectorConfig103 return nil, ErrConnectorConfig
94 }104 }
@@ -99,7 +109,7 @@ func (c *EndpointConnector) Connect(ctx context.Context, endpoint ports.BrokerRe
99 return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}109 return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
100 }110 }
101 111
102- raw, err := c.dial(ctx, endpoint.Address)112+ raw, err := c.dial(ctx, endpoint)
103 if err != nil {113 if err != nil {
104 if raw != nil {114 if raw != nil {
105 _ = raw.Close()115 _ = raw.Close()
@@ -125,7 +135,7 @@ func (c *EndpointConnector) Connect(ctx context.Context, endpoint ports.BrokerRe
125 // The endpoint stays authoritative: re-verify the accepted authority even135 // The endpoint stays authoritative: re-verify the accepted authority even
126 // though the handshake already checked it, so a future handshake change can136 // though the handshake already checked it, so a future handshake change can
127 // never weaken what the connector publishes.137 // 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 {
129 _ = bridge.Close()139 _ = bridge.Close()
130 return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible}140 return nil, ports.BrokerError{Code: ports.BrokerErrorIncompatible}
131 }141 }
M internal/adapters/daemonmux/connector_test.go +7 -6
@@ -64,12 +64,13 @@ func muxOpenRequest(stream uint64, policy ports.BrokerPolicy) ports.BrokerOpenSt
64 Connection: ports.BrokerConnectionID{1},64 Connection: ports.BrokerConnectionID{1},
65 Stream: ports.BrokerStreamID(stream),65 Stream: ports.BrokerStreamID(stream),
66 Policy: policy,66 Policy: policy,
67+ StartMode: ports.BrokerDaemonStartIfNeeded,
67 }68 }
68 }69 }
69 70
70 // muxEndpoint builds the resolved endpoint the broker verifies against.71 // muxEndpoint builds the resolved endpoint the broker verifies against.
71-func muxEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, address string) ports.BrokerResolvedEndpoint {72+func muxEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, address string) ports.BrokerDialTarget {
72- return ports.BrokerResolvedEndpoint{Identity: identity, Policy: policy, Address: address}73+ return ports.BrokerDialTarget{Fence: ports.BrokerEndpointFence{Local: true}, Policy: policy, Address: address, StartMode: ports.BrokerDaemonExistingOnly, ExpectedIdentity: ports.BrokerExpectedIdentity{Identity: identity, Bound: true}}
73 }74 }
74 75
75 // superviseServer is a test daemon: an AggregateListener plus a76 // superviseServer is a test daemon: an AggregateListener plus a
@@ -114,7 +115,7 @@ func newSuperviseServer(t *testing.T, binding ServerBinding, ceilings MuxCeiling
114 // dial returns a RawCarrierDialer that produces one fresh real carriage per115 // dial returns a RawCarrierDialer that produces one fresh real carriage per
115 // call and adopts its daemon end.116 // call and adopts its daemon end.
116 func (s *superviseServer) dial() RawCarrierDialer {117 func (s *superviseServer) dial() RawCarrierDialer {
117- return func(_ context.Context, _ string) (RawFramedTransport, error) {118+ return func(_ context.Context, _ ports.BrokerDialTarget) (RawFramedTransport, error) {
118 s.dials.Add(1)119 s.dials.Add(1)
119 client, server := rawCarriagePair(s.t)120 client, server := rawCarriagePair(s.t)
120 go func() { s.adoptions <- s.supervisor.Adopt(context.Background(), server) }()121 go func() { s.adoptions <- s.supervisor.Adopt(context.Background(), server) }()
@@ -179,16 +180,16 @@ func TestNewEndpointConnectorValidation(t *testing.T) {
179 _, err := NewEndpointConnector(nil, DefaultMuxCeilings())180 _, err := NewEndpointConnector(nil, DefaultMuxCeilings())
180 require.ErrorIs(t, err, ErrConnectorConfig)181 require.ErrorIs(t, err, ErrConnectorConfig)
181 182
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{})
183 require.ErrorIs(t, err, ErrConnectorConfig)184 require.ErrorIs(t, err, ErrConnectorConfig)
184 185
185- connector, err := NewEndpointConnector(func(context.Context, string) (RawFramedTransport, error) {186+ connector, err := NewEndpointConnector(func(context.Context, ports.BrokerDialTarget) (RawFramedTransport, error) {
186 t.Fatal("dial must not run for an invalid endpoint")187 t.Fatal("dial must not run for an invalid endpoint")
187 return nil, nil188 return nil, nil
188 }, DefaultMuxCeilings())189 }, DefaultMuxCeilings())
189 require.NoError(t, err)190 require.NoError(t, err)
190 191
191- _, err = connector.Connect(context.Background(), ports.BrokerResolvedEndpoint{})192+ _, err = connector.Connect(context.Background(), ports.BrokerDialTarget{})
192 require.Error(t, err)193 require.Error(t, err)
193 var typed ports.BrokerError194 var typed ports.BrokerError
194 require.ErrorAs(t, err, &typed)195 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) {
44 return T(value), nil44 return T(value), nil
45 }45 }
46 46
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+
47 func physicalToWire(id PhysicalStreamID) uint64 { return uint64(id) }73 func physicalToWire(id PhysicalStreamID) uint64 { return uint64(id) }
48 74
49 func physicalFromWire(value uint64) (PhysicalStreamID, error) {75 func physicalFromWire(value uint64) (PhysicalStreamID, error) {
M internal/adapters/daemonmux/doc.go +19 -4
@@ -24,11 +24,14 @@
24 //24 //
25 // The daemon-side physical binding (binding.go) and the stateful physical25 // The daemon-side physical binding (binding.go) and the stateful physical
26 // preamble handshake (handshake.go) are the connection's authority layer. A26 // preamble handshake (handshake.go) are the connection's authority layer. A
27-// ServerBinding is a validated, immutable (identity, incarnation, policy)27+// ServerBinding is a validated, immutable (identity, incarnation) value over a
28-// value. The broker-side handshake verifies the accepted response against the28+// 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
29 // independently resolved endpoint identity and policy, the nonzero31 // independently resolved endpoint identity and policy, the nonzero
30 // incarnation, and the ceilings it offered; the daemon-side handshake enforces32 // incarnation, and the ceilings it offered; the daemon-side handshake enforces
31-// the immutable binding instead of echoing the requested policy, negotiates33+// the immutable binding instead of echoing the requested policy, resolves the
34+// accepted member (policy plus origin), negotiates
32 // the element-wise minima, and refuses with the precise shared rejection code.35 // the element-wise minima, and refuses with the precise shared rejection code.
33 // One caller-supplied context or deadline bounds the single request/response36 // One caller-supplied context or deadline bounds the single request/response
34 // exchange, and every failure closes the carrier; a success leaves it open for37 // exchange, and every failure closes the carrier; a success leaves it open for
@@ -101,7 +104,14 @@
101 // (protocol.HandshakeTimeout) that queue delay and the session handshake share;104 // (protocol.HandshakeTimeout) that queue delay and the session handshake share;
102 // the deadline and the handshake completion signal are exposed to the daemon105 // the deadline and the handshake completion signal are exposed to the daemon
103 // through the narrow optional HandshakePlumbing interface, so the daemon adopts106 // through the narrow optional HandshakePlumbing interface, so the daemon adopts
104-// this one deadline instead of starting a second one. A stream that reaches its107+// 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
105 // deadline, is refused because the accept queue is full, or is reset by the115 // deadline, is refused because the accept queue is full, or is reset by the
106 // peer is settled on its own: exactly one mux Reset, its admission slot116 // peer is settled on its own: exactly one mux Reset, its admission slot
107 // released, and no effect on a sibling or on the physical connection. An117 // released, and no effect on a sibling or on the physical connection. An
@@ -212,3 +222,8 @@
212 // raw remote stderr. daemonmux gains no process, pipe, or framing code from222 // raw remote stderr. daemonmux gains no process, pipe, or framing code from
213 // this slice.223 // this slice.
214 package daemonmux224 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
16 //16 //
17 // The client verifies the accepted response against the independently resolved17 // The client verifies the accepted response against the independently resolved
18 // endpoint it dialed: the response identity must exactly equal18 // endpoint it dialed: the response identity must exactly equal
19-// ports.BrokerResolvedEndpoint.Identity, the accepted policy must exactly equal19+// ports.BrokerDialTarget.ExpectedIdentity.Identity, the accepted policy must exactly equal
20 // the endpoint policy, the incarnation must be nonzero, and the effective20 // the endpoint policy, the incarnation must be nonzero, and the effective
21 // ceilings must never exceed what the client offered. The response is the21 // ceilings must never exceed what the client offered. The response is the
22 // server's authority, never a value the client supplied and got echoed back.22 // server's authority, never a value the client supplied and got echoed back.
@@ -97,6 +97,13 @@ type ClientHandshakeResult struct {
97 type ServerHandshakeResult struct {97 type ServerHandshakeResult struct {
98 Ceilings MuxCeilings98 Ceilings MuxCeilings
99 Binding ServerBinding99 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
100 }107 }
101 108
102 // RunClientHandshake performs the broker side of the daemonmux physical109 // RunClientHandshake performs the broker side of the daemonmux physical
@@ -108,7 +115,7 @@ type ServerHandshakeResult struct {
108 // authority mismatch. The caller's ctx (or its deadline) bounds both the send115 // authority mismatch. The caller's ctx (or its deadline) bounds both the send
109 // and the receive. Every failure closes carrier; success leaves it open and116 // and the receive. Every failure closes carrier; success leaves it open and
110 // does not start a pump.117 // 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) {
112 if ctx == nil {119 if ctx == nil {
113 ctx = context.Background()120 ctx = context.Background()
114 }121 }
@@ -168,11 +175,14 @@ func RunClientHandshake(ctx context.Context, carrier FramedCarrier, endpoint por
168 // incarnation is nonzero, and the effective ceilings never exceed the offer.175 // incarnation is nonzero, and the effective ceilings never exceed the offer.
169 // A non-acceptance is refused too, so a caller can never treat a refusal as an176 // A non-acceptance is refused too, so a caller can never treat a refusal as an
170 // accepted empty response.177 // 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 {
172 if !response.Accepted {179 if !response.Accepted {
173 return ErrHandshakeRejected180 return ErrHandshakeRejected
174 }181 }
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 {
176 return ErrHandshakeRejected186 return ErrHandshakeRejected
177 }187 }
178 if !response.Policy.Compatible(endpoint.Policy) {188 if !response.Policy.Compatible(endpoint.Policy) {
@@ -222,7 +232,8 @@ func RunServerHandshake(ctx context.Context, carrier FramedCarrier, binding Serv
222 closeFailedHandshake(carrier)232 closeFailedHandshake(carrier)
223 return ServerHandshakeResult{}, &HandshakeRefusal{Code: code}233 return ServerHandshakeResult{}, &HandshakeRefusal{Code: code}
224 }234 }
225- if !binding.Accepts(request.Policy) {235+ accepted, ok := binding.Accepted(request.Policy)
236+ if !ok {
226 code := uint32(RejectionLimitRefused)237 code := uint32(RejectionLimitRefused)
227 _ = sendHandshakeRefusal(ctx, carrier, offered, code)238 _ = sendHandshakeRefusal(ctx, carrier, offered, code)
228 closeFailedHandshake(carrier)239 closeFailedHandshake(carrier)
@@ -230,7 +241,7 @@ func RunServerHandshake(ctx context.Context, carrier FramedCarrier, binding Serv
230 }241 }
231 242
232 effective := EffectiveMuxCeilings(offered, request.Ceilings)243 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)
234 if err != nil {245 if err != nil {
235 closeFailedHandshake(carrier)246 closeFailedHandshake(carrier)
236 return ServerHandshakeResult{}, ErrHandshakeConfig247 return ServerHandshakeResult{}, ErrHandshakeConfig
@@ -244,7 +255,7 @@ func RunServerHandshake(ctx context.Context, carrier FramedCarrier, binding Serv
244 closeFailedHandshake(carrier)255 closeFailedHandshake(carrier)
245 return ServerHandshakeResult{}, err256 return ServerHandshakeResult{}, err
246 }257 }
247- return ServerHandshakeResult{Ceilings: effective, Binding: binding}, nil258+ return ServerHandshakeResult{Ceilings: effective, Binding: binding, Policy: accepted.Policy, Origin: accepted.Origin}, nil
248 }259 }
249 260
250 // decodePreambleRequestWithCode is the server's strict request decode paired261 // decodePreambleRequestWithCode is the server's strict request decode paired
M internal/adapters/daemonmux/handshake_test.go +14 -2
@@ -15,8 +15,8 @@ import (
15 15
16 // handshakeEndpoint is the independently resolved endpoint the client verifies16 // handshakeEndpoint is the independently resolved endpoint the client verifies
17 // the daemon response against.17 // the daemon response against.
18-func handshakeEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy) ports.BrokerResolvedEndpoint {18+func handshakeEndpoint(identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy) ports.BrokerDialTarget {
19- return ports.BrokerResolvedEndpoint{Identity: identity, Policy: policy, Address: "quic://127.0.0.1:7777"}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}}
20 }20 }
21 21
22 func mustServerBinding(t *testing.T) ServerBinding {22 func mustServerBinding(t *testing.T) ServerBinding {
@@ -26,6 +26,16 @@ func mustServerBinding(t *testing.T) ServerBinding {
26 return binding26 return binding
27 }27 }
28 28
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+
29 // encodedAcceptedResponse builds one valid acceptance and lets the caller39 // encodedAcceptedResponse builds one valid acceptance and lets the caller
30 // corrupt exactly one field before marshaling.40 // corrupt exactly one field before marshaling.
31 func encodedAcceptedResponse(t *testing.T, mutate func(*wire.MuxPreambleResponse)) []byte {41 func encodedAcceptedResponse(t *testing.T, mutate func(*wire.MuxPreambleResponse)) []byte {
@@ -274,6 +284,8 @@ func TestServerHandshakeAcceptsAndNegotiates(t *testing.T) {
274 require.NoError(t, err)284 require.NoError(t, err)
275 require.Equal(t, binding, result.Binding)285 require.Equal(t, binding, result.Binding)
276 require.Equal(t, EffectiveMuxCeilings(clientCeilings, serverCeilings), result.Ceilings)286 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")
277 require.Equal(t, 0, carrier.closeCalls())289 require.Equal(t, 0, carrier.closeCalls())
278 290
279 decoded, err := DecodePreambleResponseBytes(awaitSentFrame(t, carrier))291 decoded, err := DecodePreambleResponseBytes(awaitSentFrame(t, carrier))
M internal/adapters/daemonmux/listener.go +107 -27
@@ -103,6 +103,13 @@ const (
103 listenerClosedText = "listener closed"103 listenerClosedText = "listener closed"
104 handshakeDeadlineText = "handshake deadline exceeded"104 handshakeDeadlineText = "handshake deadline exceeded"
105 105
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+
106 // sessionCarriageClosedText is the bounded, presentation-safe text carried113 // sessionCarriageClosedText is the bounded, presentation-safe text carried
107 // by the stream-local Reset a listener connection sends when its inner114 // by the stream-local Reset a listener connection sends when its inner
108 // session carriage closed on its own during the handshake (a sessionwire115 // session carriage closed on its own during the handshake (a sessionwire
@@ -137,6 +144,11 @@ type Listener struct {
137 clock ports.Clock144 clock ports.Clock
138 budget time.Duration145 budget time.Duration
139 queueLimit int146 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
140 152
141 // mu guards the accept queue, the deadline registry, and the closed flag.153 // mu guards the accept queue, the deadline registry, and the closed flag.
142 // change is the closed-and-replaced broadcast channel every blocked Accept154 // change is the closed-and-replaced broadcast channel every blocked Accept
@@ -154,46 +166,56 @@ type Listener struct {
154 }166 }
155 167
156 var (168 var (
157- _ ports.ServerListener = (*Listener)(nil)169+ _ ports.ServerListener = (*Listener)(nil)
158- _ ports.ServerConnection = (*ListenerConnection)(nil)170+ _ ports.ServerConnection = (*ListenerConnection)(nil)
159- _ HandshakePlumbing = (*ListenerConnection)(nil)171+ _ HandshakePlumbing = (*ListenerConnection)(nil)
172+ _ ports.SessionAdmissionProvider = (*ListenerConnection)(nil)
160 )173 )
161 174
162 // admittedStream is one inbound logical stream the engine admitted and the175 // admittedStream is one inbound logical stream the engine admitted and the
163 // listener tracks until it is settled: delivered with a completed handshake, or176 // 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.
165 type admittedStream struct {179 type admittedStream struct {
166- ref StreamRef180+ ref StreamRef
167- deadline time.Time181+ deadline time.Time
168- done <-chan struct{}182+ done <-chan struct{}
169- queued bool183+ admission ports.SessionAdmission
170- settled bool184+ queued bool
185+ settled bool
171 }186 }
172 187
173 // NewListener returns a listener over one daemon-side pump under the package188 // NewListener returns a listener over one daemon-side pump under the package
174 // policy: the accepted absolute handshake deadline budget is189 // policy: the accepted absolute handshake deadline budget is
175 // protocol.HandshakeTimeout, and the accept queue is bounded by190 // protocol.HandshakeTimeout, and the accept queue is bounded by
176-// MaxAcceptQueue. The pump must decode broker-to-daemon frames191+// MaxAcceptQueue. accepted is the provisioned carriage shape the physical
177-// (Inbound == DirectionClient) and emit daemon-to-broker frames, as the daemon192+// handshake admitted: its exact policy and its locality origin. Every admitted
178-// side of a pooled physical connection does; a nil, engine-less, or193+// session connection is stamped with it. The pump must decode broker-to-daemon
179-// broker-side pump is refused. The pump must not have started: the listener194+// frames (Inbound == DirectionClient) and emit daemon-to-broker frames, as the
180-// registers the pump's admission observer, and a pump that already started195+// daemon side of a pooled physical connection does; a nil, engine-less, or
181-// refuses the registration with ErrAdmissionObserverLate (wrapped in196+// broker-side pump, or an invalid accepted entry, is refused. The pump must not
182-// ErrListenerConfig), because an observer registered after Start could miss an197+// have started: the listener registers the pump's admission observer, and a
183-// already-admitted Open.198+// pump that already started refuses the registration with
184-func NewListener(pump *Pump) (*Listener, error) {199+// ErrAdmissionObserverLate (wrapped in ErrListenerConfig), because an observer
185- return newListener(pump, protocol.HandshakeTimeout, MaxAcceptQueue, clock.New())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)
186 }203 }
187 204
188 // newListener builds a listener from explicit policy. budget is the handshake205 // newListener builds a listener from explicit policy. budget is the handshake
189 // deadline budget each stream receives at admission, limit is the accept-queue206 // deadline budget each stream receives at admission, limit is the accept-queue
190-// bound, and clock supplies the admission time and the deadline timer. The207+// bound, clock supplies the admission time and the deadline timer, and accepted
191-// exported constructor delegates with the package policy; a test tightens the208+// is the provisioned carriage shape stamped on every admitted session
192-// budget, the queue bound, or the clock without changing live behavior.209+// connection. The exported constructor delegates with the package policy; a
193-func newListener(pump *Pump, budget time.Duration, limit int, timeSource ports.Clock) (*Listener, error) {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) {
194 if pump == nil || pump.Engine() == nil || pump.Inbound() != DirectionClient {213 if pump == nil || pump.Engine() == nil || pump.Inbound() != DirectionClient {
195 return nil, ErrListenerConfig214 return nil, ErrListenerConfig
196 }215 }
216+ if err := accepted.Validate(); err != nil {
217+ return nil, fmt.Errorf("%w: %w", ErrListenerConfig, err)
218+ }
197 if budget <= 0 {219 if budget <= 0 {
198 budget = protocol.HandshakeTimeout220 budget = protocol.HandshakeTimeout
199 }221 }
@@ -208,6 +230,7 @@ func newListener(pump *Pump, budget time.Duration, limit int, timeSource ports.C
208 clock: timeSource,230 clock: timeSource,
209 budget: budget,231 budget: budget,
210 queueLimit: limit,232 queueLimit: limit,
233+ accepted: accepted,
211 active: make(map[PhysicalStreamID]*admittedStream),234 active: make(map[PhysicalStreamID]*admittedStream),
212 change: make(chan struct{}),235 change: make(chan struct{}),
213 done: make(chan struct{}),236 done: make(chan struct{}),
@@ -294,16 +317,60 @@ func (l *Listener) Close() error {
294 return nil317 return nil
295 }318 }
296 319
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+
297 // onAdmitted is the pump's admission observer. It runs on the pump's reader356 // 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 the357+// 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
299 // stream under its admission deadline, or refuses exactly that stream when the360 // stream under its admission deadline, or refuses exactly that stream when the
300 // accept queue is full or the listener is closed. It never blocks - the reader361 // accept queue is full or the listener is closed. It never blocks - the reader
301 // is the one goroutine that applies every inbound frame of the connection.362 // is the one goroutine that applies every inbound frame of the connection.
302 func (l *Listener) onAdmitted(open Open) {363 func (l *Listener) onAdmitted(open Open) {
303 admitted := l.clock.Now()364 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+ }
304 entry := &admittedStream{370 entry := &admittedStream{
305- ref: open.Ref,371+ ref: open.Ref,
306- deadline: admitted.Add(l.budget),372+ deadline: admitted.Add(l.budget),
373+ admission: admission,
307 }374 }
308 if status, ok := l.pump.Engine().Status(open.Ref.Physical); ok {375 if status, ok := l.pump.Engine().Status(open.Ref.Physical); ok {
309 entry.done = status.Done376 entry.done = status.Done
@@ -620,6 +687,10 @@ type ListenerConnection struct {
620 // admission, resolved once at construction.687 // admission, resolved once at construction.
621 deadline time.Time688 deadline time.Time
622 689
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+
623 terminal *terminalState694 terminal *terminalState
624 watchDone chan struct{}695 watchDone chan struct{}
625 closeOnce sync.Once696 closeOnce sync.Once
@@ -636,7 +707,7 @@ func newListenerConnection(l *Listener, entry *admittedStream) *ListenerConnecti
636 pipe := newMuxStreamPipe(l.pump, entry.ref.Physical, limit, entry.done)707 pipe := newMuxStreamPipe(l.pump, entry.ref.Physical, limit, entry.done)
637 raw := newMuxTransport(pipe)708 raw := newMuxTransport(pipe)
638 connection := &ListenerConnection{709 connection := &ListenerConnection{
639- ServerConnection: sessionwire.NewServerConnectionWithDeadline(raw, entry.deadline),710+ ServerConnection: sessionwire.NewServerConnectionWithAdmission(raw, entry.deadline, entry.admission),
640 listener: l,711 listener: l,
641 pump: l.pump,712 pump: l.pump,
642 ref: entry.ref,713 ref: entry.ref,
@@ -645,6 +716,7 @@ func newListenerConnection(l *Listener, entry *admittedStream) *ListenerConnecti
645 stream: entry.done,716 stream: entry.done,
646 cause: l.pump.cause(entry.ref.Physical),717 cause: l.pump.cause(entry.ref.Physical),
647 deadline: entry.deadline,718 deadline: entry.deadline,
719+ admission: entry.admission.Clone(),
648 terminal: newTerminalState(),720 terminal: newTerminalState(),
649 watchDone: make(chan struct{}),721 watchDone: make(chan struct{}),
650 }722 }
@@ -660,6 +732,14 @@ func newListenerConnection(l *Listener, entry *admittedStream) *ListenerConnecti
660 // Ref returns the complete stream reference recorded at admission.732 // Ref returns the complete stream reference recorded at admission.
661 func (c *ListenerConnection) Ref() StreamRef { return c.ref }733 func (c *ListenerConnection) Ref() StreamRef { return c.ref }
662 734
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+
663 // HandshakeDeadline returns the accepted absolute local deadline of the743 // HandshakeDeadline returns the accepted absolute local deadline of the
664 // session handshake, fixed at Open admission and plumbing the exact deadline744 // session handshake, fixed at Open admission and plumbing the exact deadline
665 // the wrapped session connection started with. Queue delay and the handshake745 // 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
179 t.Helper()179 t.Helper()
180 brokerPump, daemonPump, start := newPairedPumpsUnstarted(t, ceilings)180 brokerPump, daemonPump, start := newPairedPumpsUnstarted(t, ceilings)
181 clock := newListenerClock(time.Now())181 clock := newListenerClock(time.Now())
182- listener, err := newListener(daemonPump, budget, queueLimit, clock)182+ listener, err := newListener(daemonPump, budget, queueLimit, clock, localAcceptance(logicalTestPolicy()))
183 require.NoError(t, err)183 require.NoError(t, err)
184 t.Cleanup(func() { _ = listener.Close() })184 t.Cleanup(func() { _ = listener.Close() })
185 start()185 start()
@@ -351,20 +351,27 @@ func (d *listenerDaemon) await(t *testing.T, count int) {
351 }351 }
352 352
353 func TestNewListenerValidation(t *testing.T) {353 func TestNewListenerValidation(t *testing.T) {
354+ local := localAcceptance(testPolicy())
354 t.Run("nil pump", func(t *testing.T) {355 t.Run("nil pump", func(t *testing.T) {
355- listener, err := NewListener(nil)356+ listener, err := NewListener(nil, local)
356 require.ErrorIs(t, err, ErrListenerConfig)357 require.ErrorIs(t, err, ErrListenerConfig)
357 require.Nil(t, listener)358 require.Nil(t, listener)
358 })359 })
359 t.Run("broker-side pump is refused", func(t *testing.T) {360 t.Run("broker-side pump is refused", func(t *testing.T) {
360 pump, _ := newTestPump(t, DirectionServer)361 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{})
362 require.ErrorIs(t, err, ErrListenerConfig)369 require.ErrorIs(t, err, ErrListenerConfig)
363 require.Nil(t, listener)370 require.Nil(t, listener)
364 })371 })
365 t.Run("daemon-side pump is accepted", func(t *testing.T) {372 t.Run("daemon-side pump is accepted", func(t *testing.T) {
366 pump, _ := newTestPump(t, DirectionClient)373 pump, _ := newTestPump(t, DirectionClient)
367- listener, err := NewListener(pump)374+ listener, err := NewListener(pump, local)
368 require.NoError(t, err)375 require.NoError(t, err)
369 require.NotNil(t, listener)376 require.NotNil(t, listener)
370 require.Equal(t, "daemonmux", listener.Addr())377 require.Equal(t, "daemonmux", listener.Addr())
@@ -1211,7 +1218,7 @@ func TestNewListenerRefusesStartedPump(t *testing.T) {
1211 pump, _ := newTestPump(t, DirectionClient)1218 pump, _ := newTestPump(t, DirectionClient)
1212 pump.Start(context.Background())1219 pump.Start(context.Background())
1213 1220
1214- listener, err := NewListener(pump)1221+ listener, err := NewListener(pump, localAcceptance(testPolicy()))
1215 require.ErrorIs(t, err, ErrListenerConfig)1222 require.ErrorIs(t, err, ErrListenerConfig)
1216 require.ErrorIs(t, err, ErrAdmissionObserverLate)1223 require.ErrorIs(t, err, ErrAdmissionObserverLate)
1217 require.Nil(t, listener)1224 require.Nil(t, listener)
M internal/adapters/daemonmux/logical_conn.go +4 -0
@@ -175,6 +175,10 @@ func openMessage(physical PhysicalStreamID, request ports.BrokerOpenStreamReques
175 Target: request.Target,175 Target: request.Target,
176 Env: append([]string(nil), request.Env...),176 Env: append([]string(nil), request.Env...),
177 Policy: request.Policy,177 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,
178 }182 }
179 }183 }
180 184
M internal/adapters/daemonmux/logical_conn_test.go +1 -0
@@ -134,6 +134,7 @@ func controlRequest(stream int) ports.BrokerOpenStreamRequest {
134 Connection: ports.BrokerConnectionID{1},134 Connection: ports.BrokerConnectionID{1},
135 Stream: ports.BrokerStreamID(stream),135 Stream: ports.BrokerStreamID(stream),
136 Policy: logicalTestPolicy(),136 Policy: logicalTestPolicy(),
137+ StartMode: ports.BrokerDaemonStartIfNeeded,
137 }138 }
138 }139 }
139 140
M internal/adapters/daemonmux/messages.go +4 -1
@@ -76,7 +76,9 @@ func (r StreamRef) Validate() error {
76 76
77 // Open asks the daemon to open one independently cancellable logical stream77 // Open asks the daemon to open one independently cancellable logical stream
78 // over the physical connection. Env is the per-request session environment78 // 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.
80 type Open struct {82 type Open struct {
81 Ref StreamRef83 Ref StreamRef
82 Purpose ports.BrokerStreamPurpose84 Purpose ports.BrokerStreamPurpose
@@ -88,6 +90,7 @@ type Open struct {
88 Target protocol.ExactSessionTarget90 Target protocol.ExactSessionTarget
89 Env []string91 Env []string
90 Policy ports.BrokerPolicy92 Policy ports.BrokerPolicy
93+ StartMode ports.BrokerDaemonStartMode
91 }94 }
92 95
93 func (Open) muxClientMessage() {}96 func (Open) muxClientMessage() {}
M internal/adapters/daemonmux/pool_integration_test.go +18 -11
@@ -13,16 +13,22 @@ import (
13 "github.com/bnema/vev/internal/usecase/broker"13 "github.com/bnema/vev/internal/usecase/broker"
14 )14 )
15 15
16-// p3cResolver is a test BrokerEndpointResolver: it returns one authenticated16+// p3cResolver is a test BrokerRouteAuthority: it returns one authenticated
17 // endpoint per request, keyed on the request's registration endpoint so two17 // endpoint per request, keyed on the request's registration endpoint so two
18 // compatible aliases resolve to the same (identity, policy) physical key with18 // compatible aliases resolve to the same (identity, policy) physical key with
19 // distinct adapter routes.19 // 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)
21 21
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) {
23 return f(ctx, request)23 return f(ctx, request)
24 }24 }
25 25
26+type p3cBinder struct{}
27+
28+func (p3cBinder) BindAuthenticatedIdentity(_ context.Context, request ports.BrokerIdentityBindingRequest) (ports.BrokerDaemonIdentity, error) {
29+ return request.Identity, nil
30+}
31+
26 // p3cRemoteRequest builds one valid remote control-purpose request.32 // p3cRemoteRequest builds one valid remote control-purpose request.
27 func p3cRemoteRequest(connection ports.BrokerConnectionID, endpoint string, stream uint64, policy ports.BrokerPolicy) ports.BrokerOpenStreamRequest {33 func p3cRemoteRequest(connection ports.BrokerConnectionID, endpoint string, stream uint64, policy ports.BrokerPolicy) ports.BrokerOpenStreamRequest {
28 registration := domain.RemoteRegistration{34 registration := domain.RemoteRegistration{
@@ -39,6 +45,7 @@ func p3cRemoteRequest(connection ports.BrokerConnectionID, endpoint string, stre
39 Endpoint: endpoint,45 Endpoint: endpoint,
40 Registration: registration,46 Registration: registration,
41 Policy: policy,47 Policy: policy,
48+ StartMode: ports.BrokerDaemonStartIfNeeded,
42 }49 }
43 }50 }
44 51
@@ -51,17 +58,17 @@ func p3cPoolLimits() broker.PoolLimits {
51 // compatible aliases over exactly one daemonmux physical connection: only one58 // compatible aliases over exactly one daemonmux physical connection: only one
52 // carriage is dialed, and both logical streams flow over it.59 // carriage is dialed, and both logical streams flow over it.
53 func TestPoolSharesCompatibleAliasesOverOnePhysical(t *testing.T) {60 func TestPoolSharesCompatibleAliasesOverOnePhysical(t *testing.T) {
54- binding := mustServerBinding(t)61+ binding := mustRemoteServerBinding(t)
55 policy := binding.Policy()62 policy := binding.Policy()
56 server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil)63 server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil)
57 server.serve()64 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) {
59 return muxEndpoint(binding.Identity(), policy, "raw://"+request.Endpoint), nil66 return muxEndpoint(binding.Identity(), policy, "raw://"+request.Endpoint), nil
60 })67 })
61 68
62 connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings())69 connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings())
63 require.NoError(t, err)70 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())
65 require.NoError(t, err)72 require.NoError(t, err)
66 t.Cleanup(func() { _ = pool.Close() })73 t.Cleanup(func() { _ = pool.Close() })
67 74
@@ -95,13 +102,13 @@ func TestPoolSharesCompatibleAliasesOverOnePhysical(t *testing.T) {
95 func TestPoolRefusesConflictingPolicyWithoutDial(t *testing.T) {102 func TestPoolRefusesConflictingPolicyWithoutDial(t *testing.T) {
96 binding := mustServerBinding(t)103 binding := mustServerBinding(t)
97 server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil)104 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) {
99 return muxEndpoint(binding.Identity(), binding.Policy(), "raw://conflict"), nil106 return muxEndpoint(binding.Identity(), binding.Policy(), "raw://conflict"), nil
100 })107 })
101 108
102 connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings())109 connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings())
103 require.NoError(t, err)110 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())
105 require.NoError(t, err)112 require.NoError(t, err)
106 t.Cleanup(func() { _ = pool.Close() })113 t.Cleanup(func() { _ = pool.Close() })
107 114
@@ -119,17 +126,17 @@ func TestPoolRefusesConflictingPolicyWithoutDial(t *testing.T) {
119 // per affected logical stream through the pool: the pooled stream terminates126 // per affected logical stream through the pool: the pooled stream terminates
120 // with a broker stream-lost error once the accepted physical carriage dies.127 // with a broker stream-lost error once the accepted physical carriage dies.
121 func TestPoolSurfacesPhysicalLossPerStream(t *testing.T) {128 func TestPoolSurfacesPhysicalLossPerStream(t *testing.T) {
122- binding := mustServerBinding(t)129+ binding := mustRemoteServerBinding(t)
123 policy := binding.Policy()130 policy := binding.Policy()
124 server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil)131 server := newSuperviseServer(t, binding, DefaultMuxCeilings(), 0, nil)
125 server.serve()132 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) {
127 return muxEndpoint(binding.Identity(), policy, "raw://"+request.Endpoint), nil134 return muxEndpoint(binding.Identity(), policy, "raw://"+request.Endpoint), nil
128 })135 })
129 136
130 connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings())137 connector, err := NewEndpointConnector(server.dial(), DefaultMuxCeilings())
131 require.NoError(t, err)138 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())
133 require.NoError(t, err)140 require.NoError(t, err)
134 t.Cleanup(func() { _ = pool.Close() })141 t.Cleanup(func() { _ = pool.Close() })
135 142
M internal/adapters/daemonmux/quic_carriage_test.go +3 -3
@@ -54,8 +54,8 @@ func quicCarriageEndpoint(t *testing.T) (*quic.Server, quic.Readiness, string) {
54 // single-use token/nonce - and hands the raw bounded transport to the connector54 // single-use token/nonce - and hands the raw bounded transport to the connector
55 // unmodified.55 // unmodified.
56 func quicDialer(readiness quic.Readiness) RawCarrierDialer {56 func quicDialer(readiness quic.Readiness) RawCarrierDialer {
57- return func(ctx context.Context, address string) (RawFramedTransport, error) {57+ return func(ctx context.Context, target ports.BrokerDialTarget) (RawFramedTransport, error) {
58- raw, err := quic.DialMuxContext(ctx, address, readiness, quic.Config{}, quicCarriageDeadline)58+ raw, err := quic.DialMuxContext(ctx, target.Address, readiness, quic.Config{}, quicCarriageDeadline)
59 if err != nil {59 if err != nil {
60 return nil, err60 return nil, err
61 }61 }
@@ -66,7 +66,7 @@ func quicDialer(readiness quic.Readiness) RawCarrierDialer {
66 // quicDialCarriage establishes the broker end of one real QUIC mux carriage.66 // quicDialCarriage establishes the broker end of one real QUIC mux carriage.
67 func quicDialCarriage(t *testing.T, ctx context.Context, addr string, readiness quic.Readiness) RawFramedTransport {67 func quicDialCarriage(t *testing.T, ctx context.Context, addr string, readiness quic.Readiness) RawFramedTransport {
68 t.Helper()68 t.Helper()
69- raw, err := quicDialer(readiness)(ctx, addr)69+ raw, err := quicDialer(readiness)(ctx, ports.BrokerDialTarget{Address: addr})
70 require.NoError(t, err)70 require.NoError(t, err)
71 t.Cleanup(func() { _ = raw.Close() })71 t.Cleanup(func() { _ = raw.Close() })
72 return raw72 return raw
M internal/adapters/daemonmux/ssh_carriage_test.go +1 -1
@@ -183,7 +183,7 @@ func sshMuxHelperCommand(mode string, extra ...string) sshstdio.CommandSpec {
183 // specification and a counter of how many physical carriages it opened.183 // specification and a counter of how many physical carriages it opened.
184 func sshMuxDialer(spec sshstdio.CommandSpec) (RawCarrierDialer, *atomic.Int32) {184 func sshMuxDialer(spec sshstdio.CommandSpec) (RawCarrierDialer, *atomic.Int32) {
185 dials := &atomic.Int32{}185 dials := &atomic.Int32{}
186- return func(ctx context.Context, _ string) (RawFramedTransport, error) {186+ return func(ctx context.Context, _ ports.BrokerDialTarget) (RawFramedTransport, error) {
187 dials.Add(1)187 dials.Add(1)
188 return sshstdio.DialMuxContext(ctx, spec, nil)188 return sshstdio.DialMuxContext(ctx, spec, nil)
189 }, dials189 }, dials
M internal/adapters/daemonmux/supervisor.go +6 -3
@@ -177,13 +177,16 @@ func (s *ServerSupervisor) Adopt(ctx context.Context, raw RawFramedTransport) er
177 }177 }
178 // Enforce the accepted physical policy before any fresh inbound Open can be178 // Enforce the accepted physical policy before any fresh inbound Open can be
179 // admitted: the pump's engine must be restricted before the pump starts,179 // admitted: the pump's engine must be restricted before the pump starts,
180- // and the listener below is built before that start too.180+ // and the listener below is built before that start too. The accepted entry
181- if err := pump.Engine().RestrictAdmissions(result.Binding.Policy()); err != nil {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 {
182 _ = pump.Close()185 _ = pump.Close()
183 return err186 return err
184 }187 }
185 188
186- listener, err := newListener(pump, protocol.HandshakeTimeout, MaxAcceptQueue, s.clock)189+ listener, err := newListener(pump, protocol.HandshakeTimeout, MaxAcceptQueue, s.clock, accepted)
187 if err != nil {190 if err != nil {
188 _ = pump.Close()191 _ = pump.Close()
189 return err192 return err
M internal/adapters/daemonmux/supervisor_test.go +159 -3
@@ -7,15 +7,16 @@ import (
7 7
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 9
10+ "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"12 "github.com/bnema/vev/internal/protocol"
12 )13 )
13 14
14 // connectRaw builds one broker PhysicalConnection over an already established15 // connectRaw builds one broker PhysicalConnection over an already established
15 // raw carriage whose daemon end was handed to a supervisor.16 // 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 {
17 t.Helper()18 t.Helper()
18- connector, err := NewEndpointConnector(func(context.Context, string) (RawFramedTransport, error) {19+ connector, err := NewEndpointConnector(func(context.Context, ports.BrokerDialTarget) (RawFramedTransport, error) {
19 return raw, nil20 return raw, nil
20 }, DefaultMuxCeilings())21 }, DefaultMuxCeilings())
21 require.NoError(t, err)22 require.NoError(t, err)
@@ -28,7 +29,7 @@ func connectRaw(t *testing.T, raw RawFramedTransport, endpoint ports.BrokerResol
28 29
29 // awaitHandshakeBridge performs the broker side of the daemonmux physical30 // awaitHandshakeBridge performs the broker side of the daemonmux physical
30 // preamble over raw and returns the negotiated carrier, ready for a pump.31 // 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) {
32 t.Helper()33 t.Helper()
33 bridge, err := NewPreambleCarrier(raw)34 bridge, err := NewPreambleCarrier(raw)
34 require.NoError(t, err)35 require.NoError(t, err)
@@ -117,6 +118,161 @@ func TestServerSupervisorEnforcesAcceptedPolicyBeforeAdmission(t *testing.T) {
117 require.Equal(t, protocol.Pong{}, message)118 require.Equal(t, protocol.Pong{}, message)
118 }119 }
119 120
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+
120 // TestServerSupervisorServerLossIsolated proves one lost physical child never276 // TestServerSupervisorServerLossIsolated proves one lost physical child never
121 // stops the aggregate: a surviving child keeps accepting while the lost child277 // stops the aggregate: a surviving child keeps accepting while the lost child
122 // is reaped and removed.278 // 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)
82 // at path through ipc.DialMuxContext (which also verifies same-user peer82 // at path through ipc.DialMuxContext (which also verifies same-user peer
83 // credentials).83 // credentials).
84 func unixDialer(path string) RawCarrierDialer {84 func unixDialer(path string) RawCarrierDialer {
85- return func(ctx context.Context, _ string) (RawFramedTransport, error) {85+ return func(ctx context.Context, _ ports.BrokerDialTarget) (RawFramedTransport, error) {
86 raw, err := ipc.DialMuxContext(ctx, path)86 raw, err := ipc.DialMuxContext(ctx, path)
87 if err != nil {87 if err != nil {
88 return nil, err88 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 (
8 "math"8 "math"
9 "sync"9 "sync"
10 10
11+ "google.golang.org/protobuf/proto"
12+
11 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/protocol"14 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/protocol/wire"15 "github.com/bnema/vev/internal/protocol/wire"
@@ -22,7 +24,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
22 if err != nil {24 if err != nil {
23 return nil, err25 return nil, err
24 }26 }
25- remote, err := remoteTargetToWire(message.RemoteTarget)27+ sessionTarget, err := sessionAttachTargetToWire(message.SessionTarget)
26 if err != nil {28 if err != nil {
27 return nil, err29 return nil, err
28 }30 }
@@ -42,7 +44,7 @@ func helloToWire(message protocol.Hello) (*wire.Hello, error) {
42 TrueColor: message.TrueColor,44 TrueColor: message.TrueColor,
43 MaxOutputInFlight: uint32(message.MaxOutputInFlight),45 MaxOutputInFlight: uint32(message.MaxOutputInFlight),
44 Env: append([]string(nil), message.Env...),46 Env: append([]string(nil), message.Env...),
45- RemoteTarget: remote,47+ SessionTarget: sessionTarget,
46 EnvironmentPolicy: uint32(message.EnvironmentPolicy),48 EnvironmentPolicy: uint32(message.EnvironmentPolicy),
47 ExactTarget: exact,49 ExactTarget: exact,
48 PreferredTabId: string(message.PreferredTabID),50 PreferredTabId: string(message.PreferredTabID),
@@ -110,11 +112,11 @@ func helloFromWire(message *wire.Hello) (protocol.Hello, error) {
110 }112 }
111 hello.MaxOutputInFlight = window113 hello.MaxOutputInFlight = window
112 hello.Env = append([]string(nil), message.GetEnv()...)114 hello.Env = append([]string(nil), message.GetEnv()...)
113- remote, err := remoteTargetFromWire(message.GetRemoteTarget())115+ sessionTarget, err := sessionAttachTargetFromWire(message.GetSessionTarget())
114 if err != nil {116 if err != nil {
115 return protocol.Hello{}, err117 return protocol.Hello{}, err
116 }118 }
117- hello.RemoteTarget = remote119+ hello.SessionTarget = sessionTarget
118 hello.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy())120 hello.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy())
119 if err != nil {121 if err != nil {
120 return protocol.Hello{}, err122 return protocol.Hello{}, err
@@ -203,11 +205,47 @@ func committedIdentityFromWire(message *wire.CommittedRouteIdentity) (protocol.C
203 return identity, nil205 return identity, nil
204 }206 }
205 207
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+
206 func attachTargetToWire(message protocol.AttachTarget) (*wire.AttachTarget, error) {244 func attachTargetToWire(message protocol.AttachTarget) (*wire.AttachTarget, error) {
207 if err := protocol.ValidateAttachTarget(message); err != nil {245 if err := protocol.ValidateAttachTarget(message); err != nil {
208 return nil, err246 return nil, err
209 }247 }
210- remote, err := remoteTargetToWire(message.RemoteTarget)248+ sessionTarget, err := sessionAttachTargetToWire(message.SessionTarget)
211 if err != nil {249 if err != nil {
212 return nil, err250 return nil, err
213 }251 }
@@ -216,7 +254,7 @@ func attachTargetToWire(message protocol.AttachTarget) (*wire.AttachTarget, erro
216 Endpoint: message.Endpoint,254 Endpoint: message.Endpoint,
217 Session: message.Session,255 Session: message.Session,
218 Intent: uint32(message.Intent),256 Intent: uint32(message.Intent),
219- RemoteTarget: remote,257+ SessionTarget: sessionTarget,
220 EnvironmentPolicy: uint32(message.EnvironmentPolicy),258 EnvironmentPolicy: uint32(message.EnvironmentPolicy),
221 ExactTarget: exactTargetToWire(message.ExactTarget),259 ExactTarget: exactTargetToWire(message.ExactTarget),
222 SamePeer: message.SamePeer,260 SamePeer: message.SamePeer,
@@ -238,11 +276,11 @@ func attachTargetFromWire(message *wire.AttachTarget) (protocol.AttachTarget, er
238 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget276 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
239 }277 }
240 target.Intent = intent278 target.Intent = intent
241- remote, err := remoteTargetFromWire(message.GetRemoteTarget())279+ sessionTarget, err := sessionAttachTargetFromWire(message.GetSessionTarget())
242 if err != nil {280 if err != nil {
243 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget281 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
244 }282 }
245- target.RemoteTarget = remote283+ target.SessionTarget = sessionTarget
246 target.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy())284 target.EnvironmentPolicy, err = enum8[protocol.EnvironmentPolicy](message.GetEnvironmentPolicy())
247 if err != nil {285 if err != nil {
248 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget286 return protocol.AttachTarget{}, protocol.ErrInvalidAttachTarget
M internal/adapters/sessionwire/proto_convert_test.go +24 -0
@@ -134,6 +134,30 @@ func TestProtoServerRoundTrips(t *testing.T) {
134 }134 }
135 }135 }
136 136
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+
137 // TestProtoWrongDirectionRejects proves a server payload in a client161 // TestProtoWrongDirectionRejects proves a server payload in a client
138 // envelope (and vice versa) fails before any mutation.162 // envelope (and vice versa) fails before any mutation.
139 func TestProtoWrongDirectionRejects(t *testing.T) {163 func TestProtoWrongDirectionRejects(t *testing.T) {
M internal/adapters/sessionwire/server.go +66 -4
@@ -34,6 +34,14 @@ type serverConnection struct {
34 preambleErr error34 preambleErr error
35 deadline time.Time35 deadline time.Time
36 36
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+
37 // preambleDone closes exactly once when the handshake has run, whether it45 // preambleDone closes exactly once when the handshake has run, whether it
38 // succeeded or failed. It backs the handshake completion seam a mux listener46 // succeeded or failed. It backs the handshake completion seam a mux listener
39 // exposes to the daemon; the absolute deadline is never restarted.47 // exposes to the daemon; the absolute deadline is never restarted.
@@ -45,14 +53,18 @@ type serverConnection struct {
45 hooks []func()53 hooks []func()
46 }54 }
47 55
48-var _ ports.ServerConnection = (*serverConnection)(nil)56+var (
57+ _ ports.ServerConnection = (*serverConnection)(nil)
58+ _ ports.SessionAdmissionProvider = (*serverConnection)(nil)
59+)
49 60
50 // NewServerConnection wraps one raw connection incarnation exactly once. The61 // NewServerConnection wraps one raw connection incarnation exactly once. The
51 // server preamble runs on the first ReceiveClient, bounded by the handshake62 // server preamble runs on the first ReceiveClient, bounded by the handshake
52 // deadline started here; the daemon's transport watcher closes the link on63 // deadline started here; the daemon's transport watcher closes the link on
53 // timeout, which unblocks the preamble read. Prefer64 // timeout, which unblocks the preamble read. Prefer
54 // NewServerConnectionWithDeadline when the caller already owns the absolute65 // 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.
56 func NewServerConnection(raw wire.Transport) ports.ServerConnection {68 func NewServerConnection(raw wire.Transport) ports.ServerConnection {
57 return NewServerConnectionWithDeadline(raw, time.Now().Add(protocol.HandshakeTimeout))69 return NewServerConnectionWithDeadline(raw, time.Now().Add(protocol.HandshakeTimeout))
58 }70 }
@@ -63,15 +75,65 @@ func NewServerConnection(raw wire.Transport) ports.ServerConnection {
63 // so a stream delayed in an accept queue spends that one budget instead of75 // so a stream delayed in an accept queue spends that one budget instead of
64 // starting a second one. The preamble still runs lazily on the first76 // starting a second one. The preamble still runs lazily on the first
65 // ReceiveClient; a deadline already in the past fails it immediately. A zero77 // 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.
67 func NewServerConnectionWithDeadline(raw wire.Transport, deadline time.Time) ports.ServerConnection {80 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 {
68 if raw == nil {111 if raw == nil {
69 return nil112 return nil
70 }113 }
71 if deadline.IsZero() {114 if deadline.IsZero() {
72 deadline = time.Now().Add(protocol.HandshakeTimeout)115 deadline = time.Now().Add(protocol.HandshakeTimeout)
73 }116 }
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
75 }137 }
76 138
77 // HandshakeDeadline returns the absolute local deadline of the session139 // 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) {
163 result.TextContains = &text163 result.TextContains = &text
164 case "status":164 case "status":
165 var status ports.UIPresentationStatus165 var status ports.UIPresentationStatus
166- if json.Unmarshal(raw, &status) != nil {166+ if json.Unmarshal(raw, &status) != nil || !status.Valid() {
167- return result, invalidRequest()
168- }
169- switch status {
170- case ports.UIStatusAttached, ports.UIStatusTransitioning, ports.UIStatusReconnecting, ports.UIStatusDetached:
171- default:
172 return result, invalidRequest()167 return result, invalidRequest()
173 }168 }
174 result.Status = &status169 result.Status = &status
M internal/adapters/uidriver/request_test.go +6 -0
@@ -23,6 +23,12 @@ func TestDecodeRequestStrictOperationSchemas(t *testing.T) {
23 {"duplicate", `{"version":1,"id":1,"id":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest},23 {"duplicate", `{"version":1,"id":1,"id":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest},
24 {"escaped duplicate", `{"version":1,"id":1,"\u0069d":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest},24 {"escaped duplicate", `{"version":1,"id":1,"\u0069d":2,"op":"capture","attachment":"a"}`, ports.UIErrInvalidRequest},
25 {"nested duplicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"status":"attached","status":"detached"}}`, ports.UIErrInvalidRequest},25 {"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},
26 {"nested unknown", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"focus":{"tab_id":"t","pane_id":"p","extra":"x"}}}`, ports.UIErrInvalidRequest},32 {"nested unknown", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"focus":{"tab_id":"t","pane_id":"p","extra":"x"}}}`, ports.UIErrInvalidRequest},
27 {"unknown predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"regex":".*"}}`, ports.UIErrInvalidRequest},33 {"unknown predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{"regex":".*"}}`, ports.UIErrInvalidRequest},
28 {"empty predicate", `{"version":1,"id":1,"op":"wait","attachment":"a","expect":{}}`, ports.UIErrInvalidRequest},34 {"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
82 screen: screen,82 screen: screen,
83 mirror: allowOversized,83 mirror: allowOversized,
84 geometry: geometry,84 geometry: geometry,
85- context: ports.UIContext{AttachmentHandle: attachmentHandle, Generation: 1},85+ context: ports.UIContext{AttachmentHandle: attachmentHandle},
86 available: true,86 available: true,
87 captureTooLarge: oversized,87 captureTooLarge: oversized,
88 changes: make(chan struct{}, 1),88 changes: make(chan struct{}, 1),
@@ -195,12 +195,7 @@ func (t *Terminal) BeginOutput(context ports.UIContext) {
195 t.mu.Lock()195 t.mu.Lock()
196 defer t.mu.Unlock()196 defer t.mu.Unlock()
197 if t.txDepth == 0 {197 if t.txDepth == 0 {
198- if context.AttachmentHandle == "" {198+ context = t.completeContextLocked(context)
199- context.AttachmentHandle = t.context.AttachmentHandle
200- }
201- if context.Generation == 0 {
202- context.Generation = t.context.Generation
203- }
204 t.context = context199 t.context = context
205 t.txFailed = false200 t.txFailed = false
206 }201 }
@@ -234,15 +229,29 @@ func (t *Terminal) PublishContext(context ports.UIContext) error {
234 if t.closed || !t.available {229 if t.closed || !t.available {
235 return ports.ErrUIUnavailable230 return ports.ErrUIUnavailable
236 }231 }
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+ }
237 if context.AttachmentHandle == "" {248 if context.AttachmentHandle == "" {
238 context.AttachmentHandle = t.context.AttachmentHandle249 context.AttachmentHandle = t.context.AttachmentHandle
239 }250 }
240 if context.Generation == 0 {251 if context.Generation == 0 {
241 context.Generation = t.context.Generation252 context.Generation = t.context.Generation
242 }253 }
243- t.context = context254+ return context
244- t.publishLocked()
245- return nil
246 }255 }
247 256
248 func (t *Terminal) Snapshot() (ports.UISnapshot, error) {257 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 {
218 t.Cleanup(terminal.Close)218 t.Cleanup(terminal.Close)
219 return terminal219 return terminal
220 }220 }
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 (
8 "fmt"8 "fmt"
9 "io"9 "io"
10 "log/slog"10 "log/slog"
11+ "os"
11 "os/signal"12 "os/signal"
13+ "path/filepath"
12 "strings"14 "strings"
13 "syscall"15 "syscall"
14 "time"16 "time"
@@ -17,6 +19,8 @@ import (
17 "github.com/bnema/vev/internal/adapters/brokeripc"19 "github.com/bnema/vev/internal/adapters/brokeripc"
18 "github.com/bnema/vev/internal/adapters/brokerstore"20 "github.com/bnema/vev/internal/adapters/brokerstore"
19 "github.com/bnema/vev/internal/adapters/clock"21 "github.com/bnema/vev/internal/adapters/clock"
22+ "github.com/bnema/vev/internal/adapters/daemonidentity"
23+ "github.com/bnema/vev/internal/adapters/daemonmux"
20 "github.com/bnema/vev/internal/adapters/ipc"24 "github.com/bnema/vev/internal/adapters/ipc"
21 "github.com/bnema/vev/internal/adapters/lifecycle"25 "github.com/bnema/vev/internal/adapters/lifecycle"
22 "github.com/bnema/vev/internal/logging"26 "github.com/bnema/vev/internal/logging"
@@ -42,7 +46,10 @@ import (
42 // until a signal or its parent context ends it, or until the broker idles out,46 // until a signal or its parent context ends it, or until the broker idles out,
43 // and then shuts down in a fixed order.47 // and then shuts down in a fixed order.
44 48
45-const brokerServeCommand = "_broker-serve"49+const (
50+ brokerServeCommand = "_broker-serve"
51+ productionBrokerServeCommand = "_broker-production-serve"
52+)
46 53
47 // brokerServePoolLimits bound the offline broker pool. They are explicit, not54 // brokerServePoolLimits bound the offline broker pool. They are explicit, not
48 // derived from the environment, and small enough that a stalled peer is55 // derived from the environment, and small enough that a stalled peer is
@@ -58,6 +65,7 @@ var brokerServePoolLimits = broker.PoolLimits{
58 // brokerServeOptions is the parsed hidden invocation.65 // brokerServeOptions is the parsed hidden invocation.
59 type brokerServeOptions struct {66 type brokerServeOptions struct {
60 offlineRoot string67 offlineRoot string
68+ production bool
61 // idleGrace is zero when the operator did not override it; the supervisor69 // idleGrace is zero when the operator did not override it; the supervisor
62 // then applies its own 5m default.70 // then applies its own 5m default.
63 idleGrace time.Duration71 idleGrace time.Duration
@@ -67,6 +75,13 @@ type brokerServeOptions struct {
67 // [--idle-grace DURATION]`. Unknown flags, duplicate flags, positionals, a75 // [--idle-grace DURATION]`. Unknown flags, duplicate flags, positionals, a
68 // missing value, and a non-positive or unparsable duration are refused; the76 // missing value, and a non-positive or unparsable duration are refused; the
69 // path itself is validated later against the filesystem.77 // 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+
70 func parseBrokerServeArgs(args []string) (command, error) {85 func parseBrokerServeArgs(args []string) (command, error) {
71 if len(args) == 0 {86 if len(args) == 0 {
72 return command{}, usagef("`%s` requires --offline-root", brokerServeCommand)87 return command{}, usagef("`%s` requires --offline-root", brokerServeCommand)
@@ -118,6 +133,29 @@ func parseBrokerServeArgs(args []string) (command, error) {
118 // brokerServeDeps are the composition seams. Production supplies the real133 // brokerServeDeps are the composition seams. Production supplies the real
119 // adapters; tests override the clock, the owner lock, the logger, and the134 // adapters; tests override the clock, the owner lock, the logger, and the
120 // readiness hook so idle shutdown and cleanup are deterministic.135 // 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+
121 type brokerServeDeps struct {159 type brokerServeDeps struct {
122 clock func() ports.Clock160 clock func() ports.Clock
123 acquire func(string) (lifecycleOwnership, error)161 acquire func(string) (lifecycleOwnership, error)
@@ -144,9 +182,23 @@ func defaultBrokerServeDeps() brokerServeDeps {
144 182
145 // runBrokerServeCommand enters the hidden foreground process: it installs183 // runBrokerServeCommand enters the hidden foreground process: it installs
146 // SIGINT/SIGTERM cancellation over the parent context and runs the sandbox.184 // 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) {
148 ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)186 ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
149 defer stop()187 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+ }
150 return runBrokerServe(ctx, options, defaultBrokerServeDeps())202 return runBrokerServe(ctx, options, defaultBrokerServeDeps())
151 }203 }
152 204
@@ -211,9 +263,18 @@ func effectiveIdleGrace(config *brokerconfig.Config, explicit time.Duration) (ti
211 // supervisor open from construction until the listener and its accept drain are263 // supervisor open from construction until the listener and its accept drain are
212 // ready, so the idle timer can never fire during setup.264 // ready, so the idle timer can never fire during setup.
213 func runBrokerServe(ctx context.Context, options brokerServeOptions, deps brokerServeDeps) (retErr error) {265 func runBrokerServe(ctx context.Context, options brokerServeOptions, deps brokerServeDeps) (retErr error) {
214- layout, err := offlineLayout(options.offlineRoot)266+ var layout brokerconfig.Layout
215- if err != nil {267+ var configPath string
216- return err268+ 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)
217 }278 }
218 for _, dir := range []string{layout.Root, layout.Runtime, layout.State, layout.Log} {279 for _, dir := range []string{layout.Root, layout.Runtime, layout.State, layout.Log} {
219 if err := safedir.EnsurePrivate(dir); err != nil {280 if err := safedir.EnsurePrivate(dir); err != nil {
@@ -227,7 +288,19 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
227 if err := layout.VerifyCreated(); err != nil {288 if err := layout.VerifyCreated(); err != nil {
228 return fmt.Errorf("vev: verify broker sandbox paths: %w", err)289 return fmt.Errorf("vev: verify broker sandbox paths: %w", err)
229 }290 }
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+ }
231 if err != nil {304 if err != nil {
232 return err305 return err
233 }306 }
@@ -247,7 +320,22 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
247 }320 }
248 defer func() { retErr = errors.Join(retErr, logCloser.Close()) }()321 defer func() { retErr = errors.Join(retErr, logCloser.Close()) }()
249 322
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})
251 if err != nil {339 if err != nil {
252 return fmt.Errorf("vev: open broker sandbox store: %w", err)340 return fmt.Errorf("vev: open broker sandbox store: %w", err)
253 }341 }
@@ -283,36 +371,59 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
283 }371 }
284 defer startupLease.Release()372 defer startupLease.Release()
285 373
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+ }
287 if err != nil {383 if err != nil {
288 return err384 return err
289 }385 }
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...)
291 if err != nil {397 if err != nil {
292 return err398 return err
293 }399 }
294- resolver := config.Resolver()
295 var remoteProbe ports.BrokerHostProbe400 var remoteProbe ports.BrokerHostProbe
296- if len(config.Endpoints()) != 0 {401+ var concreteRemoteProbe *brokerRemoteProbe
297- remoteProbe = &brokerRemoteProbe{402+ if options.production || len(config.Endpoints()) != 0 {
298- epoch: epoch, resolver: resolver, connector: connector,403+ concreteRemoteProbe = &brokerRemoteProbe{
299- policy: func(endpoint string) (ports.BrokerPolicy, bool) {404+ epoch: epoch, routes: resolver, connector: connector,
300- registration, ok := config.Registration(endpoint)
301- return registration.Policy, ok
302- },
303 }405 }
406+ remoteProbe = concreteRemoteProbe
304 }407 }
305 registry, err := broker.NewRegistryWithConfig(epoch, store, remoteProbe, clk, log, registryConfig)408 registry, err := broker.NewRegistryWithConfig(epoch, store, remoteProbe, clk, log, registryConfig)
306 if err != nil {409 if err != nil {
307 return err410 return err
308 }411 }
309- // Registration order is shutdown order reversed: registering the registry412+ dynamicRoutes.hosts = registry
310- // first, then the pool, then the listener drains them listener -> pool ->413+ binder := ports.BrokerIdentityBinder(registry)
311- // 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.
312 if err := supervisor.RegisterRunner("registry", registry); err != nil {423 if err := supervisor.RegisterRunner("registry", registry); err != nil {
313 return err424 return err
314 }425 }
315- pool, err := broker.NewPool(epoch, resolver, connector, clk, brokerServePoolLimits)426+ pool, err := broker.NewPool(epoch, resolver, binder, connector, clk, brokerServePoolLimits)
316 if err != nil {427 if err != nil {
317 return err428 return err
318 }429 }
@@ -339,7 +450,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
339 go drainBrokerAccept(listener, log, started, drained, failed)450 go drainBrokerAccept(listener, log, started, drained, failed)
340 <-started451 <-started
341 startupLease.Release()452 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)
343 if deps.onReady != nil {454 if deps.onReady != nil {
344 deps.onReady(socketPath)455 deps.onReady(socketPath)
345 }456 }
@@ -358,7 +469,7 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
358 closeErr := supervisor.Close()469 closeErr := supervisor.Close()
359 supervisorClosed = true470 supervisorClosed = true
360 <-drained471 <-drained
361- log.Info("broker_offline_stopped")472+ log.Info("broker_stopped", "production", options.production)
362 return closeErr473 return closeErr
363 }474 }
364 475
@@ -372,18 +483,74 @@ func runBrokerServe(ctx context.Context, options brokerServeOptions, deps broker
372 // is never logged; any other terminal accept failure is unexpected: it is logged483 // is never logged; any other terminal accept failure is unexpected: it is logged
373 // as an error and reported on failed so the caller commits shutdown instead of484 // as an error and reported on failed so the caller commits shutdown instead of
374 // leaving a bound broker that can no longer admit work.485 // 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) {
376 if config == nil {539 if config == nil {
377 return broker.RegistryConfig{}, errors.New("vev: offline registry requires configuration")540 return broker.RegistryConfig{}, errors.New("vev: offline registry requires configuration")
378 }541 }
379 if _, ok := config.LocalBinding(); !ok {542 if _, ok := config.LocalBinding(); !ok {
380- return broker.RegistryConfig{ObservationDisabled: true}, nil543+ mode := broker.MembershipImmutable
544+ if len(config.Endpoints()) != 0 {
545+ mode = broker.MembershipMutable
546+ }
547+ return broker.RegistryConfig{ObservationDisabled: true, MembershipMode: mode}, nil
381 }548 }
382- local, err := brokerLocalObservation(config, log)549+ local, err := brokerLocalObservation(config, epoch, connector, loaders...)
383 if err != nil {550 if err != nil {
384 return broker.RegistryConfig{}, err551 return broker.RegistryConfig{}, err
385 }552 }
386- return broker.RegistryConfig{Local: local}, nil553+ return broker.RegistryConfig{Local: local, MembershipMode: broker.MembershipMutable}, nil
387 }554 }
388 555
389 func drainBrokerAccept(listener ports.BrokerListener, log *slog.Logger, started chan<- struct{}, drained chan<- struct{}, failed chan<- struct{}) {556 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 (
7 "log/slog"7 "log/slog"
8 "os"8 "os"
9 "strings"9 "strings"
10- "sync"
11 10
12 "github.com/bnema/vev/internal/adapters/brokeripc"11 "github.com/bnema/vev/internal/adapters/brokeripc"
13 "github.com/bnema/vev/internal/adapters/clock"12 "github.com/bnema/vev/internal/adapters/clock"
14 "github.com/bnema/vev/internal/adapters/term"13 "github.com/bnema/vev/internal/adapters/term"
15- "github.com/bnema/vev/internal/adapters/uidriver"
16 "github.com/bnema/vev/internal/adapters/uiterm"14 "github.com/bnema/vev/internal/adapters/uiterm"
17 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
18 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
@@ -32,11 +30,14 @@ const (
32 type brokerClientOptions struct {30 type brokerClientOptions struct {
33 offlineRoot string31 offlineRoot string
34 harness string32 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
35 }36 }
36 37
37 func parseBrokerClientArgs(args []string) (command, error) {38 func parseBrokerClientArgs(args []string) (command, error) {
38 options := brokerClientOptions{harness: offlineClientTerminal}39 options := brokerClientOptions{harness: offlineClientTerminal}
39- var rootSet, harnessSet bool40+ var rootSet, harnessSet, pickerSet bool
40 for index := 0; index < len(args); {41 for index := 0; index < len(args); {
41 switch args[index] {42 switch args[index] {
42 case "--offline-root":43 case "--offline-root":
@@ -60,6 +61,12 @@ func parseBrokerClientArgs(args []string) (command, error) {
60 return command{}, usagef("`--harness` requires terminal or ui-driver")61 return command{}, usagef("`--harness` requires terminal or ui-driver")
61 }62 }
62 index += 263 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++
63 default:70 default:
64 if strings.HasPrefix(args[index], "-") {71 if strings.HasPrefix(args[index], "-") {
65 return command{}, usagef("unknown flag %q for `%s`", args[index], brokerClientCommand)72 return command{}, usagef("unknown flag %q for `%s`", args[index], brokerClientCommand)
@@ -70,6 +77,9 @@ func parseBrokerClientArgs(args []string) (command, error) {
70 if !rootSet {77 if !rootSet {
71 return command{}, usagef("`%s` requires --offline-root", brokerClientCommand)78 return command{}, usagef("`%s` requires --offline-root", brokerClientCommand)
72 }79 }
80+ if pickerSet && options.harness != offlineClientUIDriver {
81+ return command{}, usagef("`%s` --picker requires --harness ui-driver", brokerClientCommand)
82+ }
73 return command{kind: kindBrokerClient, brokerClient: options}, nil83 return command{kind: kindBrokerClient, brokerClient: options}, nil
74 }84 }
75 85
@@ -89,136 +99,83 @@ func runBrokerClientCommand(ctx context.Context, options brokerClientOptions) er
89 }99 }
90 defer terminal.Close()100 defer terminal.Close()
91 ui := client.NewUI(terminal, clock.New())101 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+ })
93 default:114 default:
94 return usagef("unknown offline client harness %q", options.harness)115 return usagef("unknown offline client harness %q", options.harness)
95 }116 }
96 }117 }
97 118
98-// runOfflineClient is the common offline terminal composition used by the119+// runOfflineClient is the sandbox terminal harness over the shared broker
99-// physical terminal, headless UI driver, and browser terminal harnesses. The120+// client composition. It supplies the real broker IPC connector for one
100-// connector is the real broker IPC adapter and InitialNavigation opens a real121+// offline root and the no-argument ephemeral local creation, then delegates the
101-// broker logical stream; no direct daemon dialer is present.122+// whole run to runBrokerClient: the sandbox adds no picker, supervisor, dialer,
123+// or stream of its own.
102 func runOfflineClient(ctx context.Context, socket string, terminal ports.Terminal, ui *client.UI, log *slog.Logger, onState func(client.State), onFailure func(error)) error {124 func runOfflineClient(ctx context.Context, socket string, terminal ports.Terminal, ui *client.UI, log *slog.Logger, onState func(client.State), onFailure func(error)) error {
103 if terminal == nil {125 if terminal == nil {
104 return errors.New("vev: offline client requires a terminal")126 return errors.New("vev: offline client requires a terminal")
105 }127 }
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- }
129 if log != nil {128 if log != nil {
130 log.Debug("broker_offline_client", "socket", socket)129 log.Debug("broker_offline_client", "socket", socket)
131 }130 }
132- return supervisor.Run(ctx)131+ return runBrokerClient(ctx, brokerClientConfig{
133-}132+ Connector: brokeripc.NewConnector(socket, brokeripc.Config{}),
134-133+ Terminal: terminal,
135-// offlineClientRender is the production offline render callback. Every state134+ UI: ui,
136-// reaches the optional observer; the terminal is written only while the shared135+ // No-argument composition creates one ephemeral local session through
137-// client.PickerPresentation fence admits the presentation, which is exactly136+ // the closed initial-navigation union; there is no special attach path.
138-// PresentPicker. That is what lets a resize invalidation repaint the picker at137+ InitialNavigation: localEphemeralNavigation(),
139-// its current geometry while refusing the pre-attachment connecting transition,138+ AttachmentEnvironment: terminalAttachmentEnvironment(),
140-// whose admitted foreground already owns the same terminal writer.139+ SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI},
141-//140+ OnState: onState,
142-// ResizeEvents has exactly one consumer for the whole supervisor run: its141+ OnFailure: onFailure,
143-// attachment geometry collector. Picker rendering samples Geometry here at142+ })
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- }
178 }143 }
179 144
180-func currentWorkingDirectory() string {145+// offlineUIDriverHarness is the sandbox harness input: one explicitly supplied
181- cwd, err := os.Getwd()146+// offline broker socket, the virtual terminal and UI service built over it, the
182- if err != nil {147+// one-shot navigation the harness was asked to perform, and the stream the JSONL
183- return ""148+// protocol is served on. The CLI's `--picker` selects the zero picker intent; the
184- }149+// default is the same no-argument local ephemeral creation every frontend uses.
185- return cwd150+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
186 }158 }
187 159
188-func runOfflineUIDriver(ctx context.Context, socket string, terminal *uiterm.Terminal, ui *client.UI, log *slog.Logger, stream io.ReadWriteCloser) error {160+// runOfflineUIDriverClient composes the sandbox UI-driver harness over the same
189- runCtx, cancel := context.WithCancel(ctx)161+// runUIDriverClient path production uses. Its connector is the real broker IPC
190- defer cancel()162+// connector of one explicitly supplied offline root: the sandbox adds no
191- runnerDone := make(chan error, 1)163+// production fallback, starts no broker of its own, and never reaches the
192- states := make(chan client.State, 1)164+// production XDG runtime or state. No session, endpoint, or process is
193- go func() {165+// provisioned here: the harness reaches sessions only through the broker of its
194- runnerDone <- runOfflineClient(runCtx, socket, terminal, ui, log, func(state client.State) {166+// own root, exactly like the production driver.
195- select {167+func runOfflineUIDriverClient(ctx context.Context, harness offlineUIDriverHarness) error {
196- case states <- state:168+ if harness.log != nil {
197- default:169+ harness.log.Debug("broker_offline_ui_driver", "socket", harness.socket)
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))
223 }170 }
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)
224 }181 }
M internal/app/broker_offline_client_render_test.go +68 -3
@@ -152,7 +152,7 @@ func TestOfflineClientRenderRefusesNonPickerPresentations(t *testing.T) {
152 picker := offlineRenderPicker(t)152 picker := offlineRenderPicker(t)
153 153
154 observed := make(chan client.State, 8)154 observed := make(chan client.State, 8)
155- render := offlineClientRender(terminal, picker, func(state client.State) {155+ render := brokerClientRender(terminal, picker, func(state client.State) {
156 select {156 select {
157 case observed <- state:157 case observed <- state:
158 default:158 default:
@@ -192,7 +192,7 @@ func TestOfflineClientRenderFenceIsPickerPresentation(t *testing.T) {
192 for _, state := range states {192 for _, state := range states {
193 t.Run(state.Presentation.String(), func(t *testing.T) {193 t.Run(state.Presentation.String(), func(t *testing.T) {
194 terminal := newOfflineRenderTerminal(small)194 terminal := newOfflineRenderTerminal(small)
195- render := offlineClientRender(terminal, offlineRenderPicker(t), nil)195+ render := brokerClientRender(terminal, offlineRenderPicker(t), nil)
196 196
197 render(state)197 render(state)
198 198
@@ -218,7 +218,7 @@ func TestOfflineClientRenderPaintsPickerResize(t *testing.T) {
218 218
219 terminal := newOfflineRenderTerminal(small)219 terminal := newOfflineRenderTerminal(small)
220 picker := offlineRenderPicker(t)220 picker := offlineRenderPicker(t)
221- render := offlineClientRender(terminal, picker, nil)221+ render := brokerClientRender(terminal, picker, nil)
222 222
223 render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})223 render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})
224 initial, flushes := terminal.written()224 initial, flushes := terminal.written()
@@ -234,3 +234,68 @@ func TestOfflineClientRenderPaintsPickerResize(t *testing.T) {
234 require.Contains(t, strings.TrimPrefix(resized, initial), "offline-render-session", "the appended repaint is the picker frame at the new size")234 require.Contains(t, strings.TrimPrefix(resized, initial), "offline-render-session", "the appended repaint is the picker frame at the new size")
235 require.Equal(t, 2, flushes)235 require.Equal(t, 2, flushes)
236 }236 }
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
4 4
5 import (5 import (
6 "context"6 "context"
7- "encoding/json"7+ "crypto/rand"
8+ "errors"
8 "io"9 "io"
9 "net/http"10 "net/http"
10 "net/http/httptest"11 "net/http/httptest"
@@ -24,13 +25,15 @@ import (
24 "github.com/bnema/vev/internal/adapters/daemonmux"25 "github.com/bnema/vev/internal/adapters/daemonmux"
25 "github.com/bnema/vev/internal/adapters/ipc"26 "github.com/bnema/vev/internal/adapters/ipc"
26 "github.com/bnema/vev/internal/adapters/pty"27 "github.com/bnema/vev/internal/adapters/pty"
28+ "github.com/bnema/vev/internal/adapters/snapshot"
27 "github.com/bnema/vev/internal/adapters/uiterm"29 "github.com/bnema/vev/internal/adapters/uiterm"
28 "github.com/bnema/vev/internal/adapters/webterm"30 "github.com/bnema/vev/internal/adapters/webterm"
29 "github.com/bnema/vev/internal/domain"31 "github.com/bnema/vev/internal/domain"
32+ "github.com/bnema/vev/internal/persist"
30 "github.com/bnema/vev/internal/ports"33 "github.com/bnema/vev/internal/ports"
31 "github.com/bnema/vev/internal/protocol"34 "github.com/bnema/vev/internal/protocol"
32- "github.com/bnema/vev/internal/usecase/client"
33 "github.com/bnema/vev/internal/usecase/daemon"35 "github.com/bnema/vev/internal/usecase/daemon"
36+ "github.com/bnema/vev/internal/usecase/recovery"
34 "github.com/bnema/vev/pkg/safedir"37 "github.com/bnema/vev/pkg/safedir"
35 )38 )
36 39
@@ -45,9 +48,34 @@ type offlineClientFixture struct {
45 cancel context.CancelFunc48 cancel context.CancelFunc
46 prodRuntime string49 prodRuntime string
47 prodState string50 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
48 }54 }
49 55
50 func startOfflineClientFixture(t *testing.T) offlineClientFixture {56 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 {
51 t.Helper()79 t.Helper()
52 root, prodRuntime, prodState := isolateSandboxEnv(t)80 root, prodRuntime, prodState := isolateSandboxEnv(t)
53 require.NoError(t, os.MkdirAll(root, 0o700))81 require.NoError(t, os.MkdirAll(root, 0o700))
@@ -96,7 +124,22 @@ func startOfflineClientFixture(t *testing.T) offlineClientFixture {
96 }()124 }()
97 streamSeen := make(chan struct{}, 16)125 streamSeen := make(chan struct{}, 16)
98 counting := &countingServerListener{ServerListener: aggregate, seen: streamSeen}126 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...)
100 go func() { _ = d.Serve(ctx, counting) }()143 go func() { _ = d.Serve(ctx, counting) }()
101 144
102 // The client picker and registry must share wall-clock freshness semantics:145 // The client picker and registry must share wall-clock freshness semantics:
@@ -112,6 +155,28 @@ func startOfflineClientFixture(t *testing.T) offlineClientFixture {
112 case <-time.After(brokerTestWait):155 case <-time.After(brokerTestWait):
113 t.Fatal("offline broker did not become ready")156 t.Fatal("offline broker did not become ready")
114 }157 }
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())
115 t.Cleanup(func() {180 t.Cleanup(func() {
116 cancel()181 cancel()
117 _ = rawListener.Close()182 _ = rawListener.Close()
@@ -137,7 +202,12 @@ func startOfflineClientFixture(t *testing.T) offlineClientFixture {
137 defer physicalMu.Unlock()202 defer physicalMu.Unlock()
138 return len(physicalConnections)203 return len(physicalConnections)
139 }204 }
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+ }
141 }211 }
142 212
143 type countingServerListener struct {213 type countingServerListener struct {
@@ -234,85 +304,84 @@ func mustConnectivityOwner(t *testing.T, kind string) connectivityOwner {
234 return owner304 return owner
235 }305 }
236 306
237-func TestOfflineClientTerminalReachesSessionThroughBrokerIPC(t *testing.T) {307+// TestOfflineEphemeralSessionIsNotPersistedViaBroker proves the daemon-owned
238- fixture := startOfflineClientFixture(t)308+// broker fixture persists a named session but never an ephemeral one: the
239- ctx, cancel := context.WithCancel(t.Context())309+// durable catalogue holds exactly the named record after both lifecycles, so
240- terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "")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)
241 require.NoError(t, err)319 require.NoError(t, err)
242- done := make(chan error, 1)320+ defer service.Close()
243- failures := make(chan error, 1)321+
244- go func() {322+ create := func(name string, admission ports.BrokerStreamAdmission, intent uint8) {
245- done <- runOfflineClient(ctx, fixture.socket, terminal, nil, discardLog(), nil, func(err error) {323+ t.Helper()
246- select {324+ streamID, err := service.NextStreamID()
247- case failures <- err:325+ require.NoError(t, err)
248- default:326+ request := ports.BrokerOpenStreamRequest{
249- }327+ Purpose: ports.BrokerStreamAttachment, Stream: streamID, Local: true,
250- })328+ Policy: fixture.policy, Admission: admission, Name: name,
251- }()329+ StartMode: ports.BrokerDaemonStartIfNeeded,
252- select {330+ }
253- case <-fixture.streams:331+ stream, err := service.OpenStream(ctx, request)
254- case err := <-failures:332+ require.NoError(t, err)
255- t.Fatalf("offline client stream open failed: %v (%#v)", err, err)333+ require.NoError(t, stream.SendClient(protocol.Hello{
256- case <-time.After(brokerTestWait):334+ Version: protocol.Version, Intent: intent, Name: name,
257- t.Fatal("client did not reach a session through a broker logical stream")335+ Size: domain.Size{Cols: 80, Rows: 24}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
336+ }))
337+ receiveOfflineWelcome(t, stream)
338+ require.NoError(t, stream.Close())
258 }339 }
259- awaitUITerminalMarker(t, terminal, "offline-ready")340+
260- cancel()341+ create("persisted", ports.BrokerAdmissionCreateNamed, protocol.IntentNew)
261- select {342+ create("", ports.BrokerAdmissionCreateEphemeral, protocol.IntentEphemeral)
262- case err := <-done:343+
263- require.NoError(t, ignoreContextCancellation(err))344+ // The daemon persists on its own cadence; the durable catalogue must settle
264- case <-time.After(brokerTestWait):345+ // to exactly the named record and never gain the ephemeral one.
265- t.Fatal("offline terminal client did not stop")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")
266 }356 }
267 }357 }
268 358
269-func TestOfflineClientUIDriverReachesSessionThroughBrokerIPC(t *testing.T) {359+func TestOfflineClientTerminalReachesSessionThroughBrokerIPC(t *testing.T) {
270 fixture := startOfflineClientFixture(t)360 fixture := startOfflineClientFixture(t)
271 ctx, cancel := context.WithCancel(t.Context())361 ctx, cancel := context.WithCancel(t.Context())
272- defer cancel()
273 terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "")362 terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "")
274 require.NoError(t, err)363 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}
279 done := make(chan error, 1)364 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)
285 go func() {365 go func() {
286- var ready map[string]any366+ done <- runOfflineClient(ctx, fixture.socket, terminal, nil, discardLog(), nil, nil)
287- if err := decoder.Decode(&ready); err != nil {
288- readyError <- err
289- return
290- }
291- readyResult <- ready
292 }()367 }()
293- select {368+ awaitLogicalStream(t, fixture.streams)
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- }
305 awaitUITerminalMarker(t, terminal, "offline-ready")369 awaitUITerminalMarker(t, terminal, "offline-ready")
306- _ = clientEnd.Close()
307- _ = writerReader.Close()
308 cancel()370 cancel()
309 select {371 select {
310- case <-done:372+ case err := <-done:
373+ require.NoError(t, ignoreContextCancellation(err))
311 case <-time.After(brokerTestWait):374 case <-time.After(brokerTestWait):
312- t.Fatal("offline UI driver did not stop")375+ t.Fatal("offline terminal client did not stop")
313 }376 }
314 }377 }
315 378
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+
316 func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) {385 func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) {
317 fixture := startOfflineClientFixture(t)386 fixture := startOfflineClientFixture(t)
318 ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)387 ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
@@ -324,7 +393,9 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) {
324 connections := make([]ports.BrokerLogicalConnection, 3)393 connections := make([]ports.BrokerLogicalConnection, 3)
325 welcomes := make([]protocol.Welcome, 3)394 welcomes := make([]protocol.Welcome, 3)
326 for i := range connections {395 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}
328 if i == 0 {399 if i == 0 {
329 request.Admission, request.Name = ports.BrokerAdmissionCreateNamed, "shared"400 request.Admission, request.Name = ports.BrokerAdmissionCreateNamed, "shared"
330 } else {401 } else {
@@ -387,8 +458,7 @@ func TestOfflineBrokerSharedTransportKeepsAttachmentsIndependent(t *testing.T) {
387 }458 }
388 459
389 require.NoError(t, connections[0].SendClient(protocol.Detach{}))460 require.NoError(t, connections[0].SendClient(protocol.Detach{}))
390- receiveOfflineDetached(t, connections[0])461+ require.NoError(t, receiveOfflineEOF(t, connections[0]), "detach must end its logical stream with orderly EOF")
391- require.NoError(t, connections[0].Close())
392 for i := 1; i < len(connections); i++ {462 for i := 1; i < len(connections); i++ {
393 requestID := uint64(20 + i)463 requestID := uint64(20 + i)
394 require.NoError(t, connections[i].SendClient(protocol.CommandRequest{Version: protocol.Version, RequestID: requestID, Attached: true, Slug: "next-tab"}))464 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) {
410 var target protocol.ExactSessionTarget480 var target protocol.ExactSessionTarget
411 connections := make([]ports.BrokerLogicalConnection, 3)481 connections := make([]ports.BrokerLogicalConnection, 3)
412 for i := range connections {482 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}
414 if i == 0 {486 if i == 0 {
415 request.Admission, request.Name = ports.BrokerAdmissionCreateNamed, "loss"487 request.Admission, request.Name = ports.BrokerAdmissionCreateNamed, "loss"
416 } else {488 } else {
@@ -469,12 +541,27 @@ func receiveOfflineCommandResult(t *testing.T, connection ports.BrokerLogicalCon
469 return message.(protocol.CommandResult)541 return message.(protocol.CommandResult)
470 }542 }
471 543
472-func receiveOfflineDetached(t *testing.T, connection ports.BrokerLogicalConnection) {544+func receiveOfflineEOF(t *testing.T, connection ports.BrokerLogicalConnection) error {
473 t.Helper()545 t.Helper()
474- receiveOfflineMessage(t, connection, "Detached", func(message protocol.ServerMessage) bool {546+ result := make(chan error, 1)
475- _, ok := message.(protocol.Detached)547+ go func() {
476- return ok548+ for {
477- })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+ }
478 }565 }
479 566
480 func receiveOfflineMessage(t *testing.T, connection ports.BrokerLogicalConnection, want string, match func(protocol.ServerMessage) bool) protocol.ServerMessage {567 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 (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7- "log/slog"
8 7
9 "github.com/bnema/vev/internal/adapters/brokerconfig"8 "github.com/bnema/vev/internal/adapters/brokerconfig"
10 "github.com/bnema/vev/internal/adapters/daemonmux"9 "github.com/bnema/vev/internal/adapters/daemonmux"
@@ -13,140 +12,224 @@ import (
13 "github.com/bnema/vev/internal/usecase/broker"12 "github.com/bnema/vev/internal/usecase/broker"
14 )13 )
15 14
16-// Broker-owned local observation composition (Plan 001 P5.3a).15+// Broker-owned local observation composition (Plan 001 P5.3a, P7.4b).
17 //16 //
18 // The offline broker observes its own machine daemon through the same daemonmux17 // 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 the18+// carriage a local logical stream is dialed over. The probe completes the
20-// physical preamble: it dials the provisioned local Unix route, negotiates19+// authenticated physical preamble (identity, incarnation, and policy), opens
21-// identity, incarnation, and policy, and closes the carriage. It deliberately20+// exactly one logical BrokerStreamObservation stream, sends one typed
22-// opens no logical stream, so observing the local daemon never attaches to a21+// remote-catalog control request, validates the catalogue the daemon answers,
23-// session, and it only ever dials an existing local Unix socket, so it never22+// and closes the stream and carriage. It never sends a Hello, never claims a
24-// starts a stopped daemon. The composition is isolated from production state:23+// geometry, never requests an attachment admission, and never creates a
25-// it derives the probe from the sandbox configuration and never touches the24+// session or a process, and it only ever dials an existing local Unix socket, so
26-// production runtime or state directories.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.
27 //34 //
28 // The hidden offline sandbox selects this producer whenever its strict config35 // The hidden offline sandbox selects this producer whenever its strict config
29 // provisions a local binding. Ordinary production composition does not select36 // provisions a local binding. Ordinary production composition does not select
30 // it. The sandbox owns no remote membership, and probing remains no-spawn and37 // it. The sandbox owns no remote membership, and probing remains no-spawn and
31 // attachment-free even though it runs on the registry's observation cadence.38 // attachment-free even though it runs on the registry's observation cadence.
32 39
33-// localCarrierDialer establishes one raw framed daemonmux carriage for the40+// localCarrierDialer establishes one raw framed daemonmux carriage for one
34-// opaque route address the broker-owned local binding published. It is the same41+// resolved dial target. It is the same seam the pooled mux connector uses, so
35-// seam the pooled mux connector uses, so the probe can never select a transport42+// the probe can never select a transport or an address of its own, and the
36-// or an address of its own.43+// target travels whole: the address selects the route while the start
37-type localCarrierDialer func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error)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)
38 46
39 // localRouteProbe is the broker-owned ports-independent local observation47 // localRouteProbe is the broker-owned ports-independent local observation
40 // probe. It holds the resolved local endpoint authority (identity, policy, and48 // probe. It holds the resolved local endpoint authority (identity, policy, and
41-// opaque address) and dials it exactly once per observation, then closes the49+// opaque address) and one daemonmux endpoint connector over the same dial and
42-// carriage: no logical stream is ever opened and no daemon process is ever50+// ceiling seam the pooled connector uses, and observes the daemon through it.
43-// started.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+
44 type localRouteProbe struct {56 type localRouteProbe struct {
45- endpoint ports.BrokerResolvedEndpoint57+ epoch ports.BrokerEpoch
46- dial localCarrierDialer58+ route brokerconfig.Route
47- ceilings daemonmux.MuxCeilings59+ policy ports.BrokerPolicy
60+ loadIdentity localIdentityLoader
61+ connector ports.BrokerEndpointConnector
48 }62 }
49 63
50 var _ broker.LocalProbe = (*localRouteProbe)(nil)64 var _ broker.LocalProbe = (*localRouteProbe)(nil)
51 65
52-// newLocalRouteProbe fences one broker-owned local binding into a probe. The66+// newLocalRouteProbe fences one broker-owned local binding into a probe over a
53-// binding's identity, policy, and route address must validate, the ceilings67+// dial seam. The binding's identity, policy, and route address must validate,
54-// must be a valid advertisement, and a nil dial is refused: a probe that cannot68+// the ceilings must be a valid advertisement, the epoch must be the broker
55-// establish a carriage must not exist.69+// process incarnation every local stream is scoped to, and a nil dial is
56-func newLocalRouteProbe(binding brokerconfig.LocalBinding, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) {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) {
57 if dial == nil {75 if dial == nil {
58 return nil, errors.New("vev: broker local probe requires a dialer")76 return nil, errors.New("vev: broker local probe requires a dialer")
59 }77 }
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+ }
60 // Probing must never spawn a process, so only a Unix route this process106 // Probing must never spawn a process, so only a Unix route this process
61 // dials directly is acceptable. brokerconfig refuses any other kind at load107 // dials directly is acceptable. brokerconfig refuses any other kind at load
62 // time; this re-checks the exported binding a caller may have built itself,108 // time; this re-checks the exported binding a caller may have built itself,
63 // because the dial seam routes an ssh address to a child process.109 // because the dial seam routes an ssh address to a child process.
64- if !binding.Route.IsLocal() {110+ if !route.IsLocal() {
65 return nil, errors.New("vev: broker local probe requires a local route")111 return nil, errors.New("vev: broker local probe requires a local route")
66 }112 }
67- if err := ceilings.Validate(); err != nil {113+ if err := route.Validate(); err != nil {
68 return nil, fmt.Errorf("vev: broker local probe: %w", err)114 return nil, fmt.Errorf("vev: broker local probe: %w", err)
69 }115 }
70- endpoint := ports.BrokerResolvedEndpoint{Identity: binding.Identity, Policy: binding.Policy, Address: binding.Route.Address()}116+ if err := policy.Validate(); err != nil {
71- if err := endpoint.Validate(); err != nil {
72 return nil, fmt.Errorf("vev: broker local probe: %w", err)117 return nil, fmt.Errorf("vev: broker local probe: %w", err)
73 }118 }
74- return &localRouteProbe{endpoint: endpoint, dial: dial, ceilings: ceilings}, nil119+ return &localRouteProbe{epoch: epoch, route: route, policy: policy, loadIdentity: loadIdentity, connector: connector}, nil
75 }120 }
76 121
77-// ProbeLocal observes the local daemon by completing the daemonmux physical122+// ProbeLocal observes the local daemon by authenticating the daemonmux physical
78-// preamble and closing the carriage. A successful preamble reports the only123+// preamble and then opening exactly one observation stream that asks the daemon
79-// observed state the carriage exposes: the authenticated identity, the daemon124+// for its own catalogue. A successful observation reports the authenticated
80-// incarnation, the protocol version the daemon's accepted policy negotiated,125+// identity, the daemon incarnation, the protocol version the catalogue schema
81-// and reachability. Capabilities and the session catalogue are not carried by126+// negotiated, reachability, and the exact session inventory the daemon
82-// the physical preamble, so they stay unknown (zero) rather than being127+// answered. Capabilities are not carried by the catalogue command, so they stay
83-// invented, and every failure reports an explicit availability with zero128+// unknown (zero) rather than being invented, and every failure reports an
84-// observed identity: a probe never manufactures identity, version, or129+// explicit availability with zero observed identity: a probe never manufactures
85-// inventory it did not observe.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.
86 func (p *localRouteProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) {136 func (p *localRouteProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) {
87- if p == nil {137+ if p == nil || p.connector == nil {
88 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe is nil")138 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe is nil")
89 }139 }
90- raw, err := p.dial(ctx, p.endpoint.Address)140+ identity, err := p.loadIdentity()
91 if err != nil {141 if err != nil {
92- if raw != nil {
93- _ = raw.Close()
94- }
95 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err142 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
96 }143 }
97- if raw == nil {144+ if identity == "" {
98- return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe: nil carriage")145+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnknown}, nil
99 }146 }
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()
101 if err != nil {167 if err != nil {
102- _ = raw.Close()
103 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err168 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
104 }169 }
105- // RunClientHandshake closes the carriage on every failure; on success the170+ observation, err := observeDaemonCatalogue(ctx, physical, request)
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()
110 if err != nil {171 if err != nil {
111 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err172 return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
112 }173 }
113- return ports.BrokerDaemonObservation{174+ return observation, nil
114- Identity: result.Identity,175+}
115- Incarnation: result.Incarnation,176+
116- ProtocolVersion: result.Policy.ProtocolVersion,177+// request builds the one observation open this probe performs: a local
117- Availability: domain.RemoteAvailabilityReachable,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,
118 }, nil204 }, nil
119 }205 }
120 206
121 // brokerLocalProbe builds the broker-owned local probe over one validated207 // brokerLocalProbe builds the broker-owned local probe over one validated
122-// offline configuration. The dialer looks the opaque address up in the same208+// offline configuration and the broker's already owned endpoint connector, so
123-// immutable configuration the pooled connector uses, so a probe can never reach209+// the probe composes the exact transport owner the pool does instead of
124-// an address the configuration did not provision.210+// creating a second one.
125-func brokerLocalProbe(config *brokerconfig.Config, log *slog.Logger) (*localRouteProbe, error) {211+func brokerLocalProbe(config *brokerconfig.Config, epoch ports.BrokerEpoch, connector ports.BrokerEndpointConnector, loaders ...localIdentityLoader) (*localRouteProbe, error) {
126 binding, ok := config.LocalBinding()212 binding, ok := config.LocalBinding()
127 if !ok {213 if !ok {
128 return nil, errors.New("vev: broker local probe requires a provisioned local binding")214 return nil, errors.New("vev: broker local probe requires a provisioned local binding")
129 }215 }
130- dial := func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) {216+ loader := localIdentityLoader(func() (ports.BrokerDaemonIdentity, error) { return binding.Identity, nil })
131- route, ok := config.RouteByAddress(address)217+ if len(loaders) != 0 {
132- if !ok {218+ loader = loaders[0]
133- return nil, errors.New("vev: broker local probe: unknown route address")
134- }
135- return dialBrokerRoute(ctx, route, log)
136 }219 }
137- return newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())220+ return newDynamicLocalRouteProbe(binding.Route, binding.Policy, epoch, connector, loader)
138 }221 }
139 222
140 // brokerLocalObservation composes the registry's local producer from one223 // brokerLocalObservation composes the registry's local producer from one
141 // validated offline configuration: the configured display origin and policy224 // validated offline configuration: the configured display origin and policy
142 // become the local entry's authority, and the probe supplies observed state225 // become the local entry's authority, and the probe supplies observed state
143 // only. A configuration without a local binding provisions no local producer.226 // 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) {
145 binding, ok := config.LocalBinding()228 binding, ok := config.LocalBinding()
146 if !ok {229 if !ok {
147 return nil, errors.New("vev: broker local observation requires a provisioned local binding")230 return nil, errors.New("vev: broker local observation requires a provisioned local binding")
148 }231 }
149- probe, err := brokerLocalProbe(config, log)232+ probe, err := brokerLocalProbe(config, epoch, connector, loaders...)
150 if err != nil {233 if err != nil {
151 return nil, err234 return nil, err
152 }235 }
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
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "errors"6 "errors"
6 "io"7 "io"
7 "log/slog"8 "log/slog"
8 "net"9 "net"
9 "os"10 "os"
10 "path/filepath"11 "path/filepath"
12+ "sync"
11 "sync/atomic"13 "sync/atomic"
12 "testing"14 "testing"
13 "time"15 "time"
@@ -17,14 +19,20 @@ import (
17 "github.com/bnema/vev/internal/adapters/brokerconfig"19 "github.com/bnema/vev/internal/adapters/brokerconfig"
18 "github.com/bnema/vev/internal/adapters/brokerstore"20 "github.com/bnema/vev/internal/adapters/brokerstore"
19 "github.com/bnema/vev/internal/adapters/clock"21 "github.com/bnema/vev/internal/adapters/clock"
22+ "github.com/bnema/vev/internal/adapters/daemonidentity"
20 "github.com/bnema/vev/internal/adapters/daemonmux"23 "github.com/bnema/vev/internal/adapters/daemonmux"
21 "github.com/bnema/vev/internal/adapters/ipc"24 "github.com/bnema/vev/internal/adapters/ipc"
22 "github.com/bnema/vev/internal/domain"25 "github.com/bnema/vev/internal/domain"
23 "github.com/bnema/vev/internal/ports"26 "github.com/bnema/vev/internal/ports"
27+ "github.com/bnema/vev/internal/protocol"
28+ "github.com/bnema/vev/internal/protocol/catalogue"
24 "github.com/bnema/vev/internal/usecase/broker"29 "github.com/bnema/vev/internal/usecase/broker"
25 )30 )
26 31
27-const brokerLocalTestIdentity = "offline-local-daemon"32+const (
33+ brokerLocalTestIdentity = "offline-local-daemon"
34+ brokerLocalTestEpoch = ports.BrokerEpoch(11)
35+)
28 36
29 // testLocalSandboxConfig writes and loads one marker-valid sandbox whose only37 // testLocalSandboxConfig writes and loads one marker-valid sandbox whose only
30 // provisioned carriage is the broker-owned local binding.38 // provisioned carriage is the broker-owned local binding.
@@ -51,7 +59,7 @@ func testLocalSandboxConfig(t *testing.T) (*brokerconfig.Config, brokerconfig.Lo
51 },59 },
52 },60 },
53 })61 })
54- layout, err := brokerconfig.ResolveLayout(root, nil)62+ layout, err := offlineLayout(root)
55 require.NoError(t, err)63 require.NoError(t, err)
56 config, err := brokerconfig.Load(layout)64 config, err := brokerconfig.Load(layout)
57 require.NoError(t, err)65 require.NoError(t, err)
@@ -74,81 +82,268 @@ func testServerBinding(t *testing.T, binding brokerconfig.LocalBinding) daemonmu
74 return serverBinding82 return serverBinding
75 }83 }
76 84
77-// TestBrokerLocalProbeObservesWithoutAttaching proves the local probe completes85+// testLocalConnector builds the broker-owned endpoint connector a probe
78-// only the daemonmux physical preamble: it reports the daemon's authenticated86+// composes, over an explicit dial seam. It is the same connector type the pool
79-// identity, incarnation, and negotiated protocol version, and then closes the87+// uses, so a test exercises the real composition path.
80-// carriage without ever opening a logical stream (no attach). Capabilities and88+func testLocalConnector(t *testing.T, dial localCarrierDialer) ports.BrokerEndpointConnector {
81-// the session catalogue stay unknown because the preamble carries neither.89+ t.Helper()
82-func TestBrokerLocalProbeObservesWithoutAttaching(t *testing.T) {90+ connector, err := daemonmux.NewEndpointConnector(daemonmux.RawCarrierDialer(dial), daemonmux.DefaultMuxCeilings())
83- _, binding := testLocalSandboxConfig(t)91+ require.NoError(t, err)
84- serverBinding := testServerBinding(t, binding)92+ return connector
93+}
85 94
86- clientConn, serverConn := net.Pipe()95+// unreachableCarriageConnector builds a connector whose dial always fails, so a
87- t.Cleanup(func() {96+// composition test can build a probe without a scripted daemon.
88- _ = clientConn.Close()97+func unreachableCarriageConnector(t *testing.T) ports.BrokerEndpointConnector {
89- _ = serverConn.Close()98+ t.Helper()
99+ return testLocalConnector(t, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
100+ return nil, errors.New("no carriage")
90 })101 })
102+}
91 103
92- var dialed atomic.Value104+// testLocalCatalogue builds one exact-schema catalogue carrying the supplied
93- dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) {105+// sessions, marshalled exactly as the daemon's remote-catalog command does.
94- dialed.Store(address)106+func testLocalCatalogue(t *testing.T, sessions ...catalogue.RemoteCatalogSession) string {
95- return rawFramedTransport(clientConn), nil107+ t.Helper()
96- }108+ encoded, err := json.Marshal(catalogue.RemoteCatalog{
97- probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())109+ ProtocolVersion: protocol.Version,
110+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
111+ Sessions: sessions,
112+ })
98 require.NoError(t, err)113 require.NoError(t, err)
114+ return string(encoded) + "\n"
115+}
99 116
100- // The daemon side completes one preamble and then blocks reading the next117+// testLocalUpSession builds one exact, attachable catalogue session with an
101- // frame. The probe must never send one: the read ends only when the probe118+// ordered tab list.
102- // closes the carriage, which is the proof that no attachment stream opened.119+func testLocalUpSession(lifecycle domain.SessionLifecycleID, name string) catalogue.RemoteCatalogSession {
103- serverDone := make(chan error, 1)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+ })
104 go func() {158 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()
106 if err != nil {194 if err != nil {
107- serverDone <- err
108 return195 return
109 }196 }
110- if _, err := daemonmux.RunServerHandshake(context.Background(), bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil {197+ d.mu.Lock()
111- serverDone <- err198+ d.received = append(d.received, message)
199+ d.mu.Unlock()
200+ if d.respond == nil {
112 return201 return
113 }202 }
114- readCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)203+ reply := d.respond(message)
115- defer cancel()204+ if reply == nil {
116- _, recvErr := bridge.Receive(readCtx)205+ return
117- serverDone <- recvErr206+ }
118- }()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)
119 264
120 observation, err := probe.ProbeLocal(context.Background())265 observation, err := probe.ProbeLocal(context.Background())
121 require.NoError(t, err)266 require.NoError(t, err)
122 require.Equal(t, binding.Identity, observation.Identity)267 require.Equal(t, binding.Identity, observation.Identity)
123- require.Equal(t, ports.BrokerDaemonIncarnation{7, 1}, observation.Incarnation)268+ require.Equal(t, serverBinding.Incarnation(), observation.Incarnation)
124- require.Equal(t, binding.Policy.ProtocolVersion, observation.ProtocolVersion)269+ require.Equal(t, protocol.Version, observation.ProtocolVersion)
125 require.Equal(t, domain.RemoteAvailabilityReachable, observation.Availability)270 require.Equal(t, domain.RemoteAvailabilityReachable, observation.Availability)
126- require.Zero(t, observation.Capabilities, "the preamble carries no capabilities; they must stay unknown")271+ require.True(t, observation.InventoryKnown, "an answered catalogue is known inventory")
127- require.Empty(t, observation.Sessions)272+ require.Equal(t, []catalogue.RemoteCatalogSession{session}, observation.Sessions)
128- require.False(t, observation.InventoryKnown)273+ require.Zero(t, observation.Capabilities, "the catalogue command carries no capabilities; they must stay unknown")
129- require.False(t, observation.Local, "the probe supplies observed state only, never local authority")274+ require.False(t, observation.Local, "the probe supplies observed state only, never configured authority")
130 require.Empty(t, observation.Endpoint)275 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)
132 308
133- serverErr := <-serverDone309+ request, err := probe.request()
134- require.Error(t, serverErr, "the probe sent no frame after the preamble")310+ require.NoError(t, err)
135- require.True(t, errors.Is(serverErr, io.EOF) || errors.Is(serverErr, io.ErrClosedPipe) || errors.Is(serverErr, context.Canceled),311+ require.NoError(t, request.Validate())
136- "expected a closed carriage, got %v", serverErr)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())
137 }331 }
138 332
139 // TestBrokerLocalProbeFailsClosed proves an unavailable carriage yields zero333 // TestBrokerLocalProbeFailsClosed proves an unavailable carriage yields zero
140-// observed identity and an explicit availability, never an invented one, and334+// observed identity, no inventory, and an explicit availability, never an
141-// that the probe only dials the provisioned local address: it has no spawn path335+// invented one, and that the probe only dials the provisioned local address: it
142-// and never starts a stopped daemon.336+// has no spawn path and never starts a stopped daemon.
143 func TestBrokerLocalProbeFailsClosed(t *testing.T) {337 func TestBrokerLocalProbeFailsClosed(t *testing.T) {
144 _, binding := testLocalSandboxConfig(t)338 _, binding := testLocalSandboxConfig(t)
145 339
146 var addresses []string340 var addresses []string
147- dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) {341+ dial := func(_ context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
148- addresses = append(addresses, address)342+ require.Equal(t, ports.BrokerDaemonExistingOnly, target.StartMode, "an observation dial never authorizes starting the daemon")
343+ addresses = append(addresses, target.Address)
149 return nil, errors.New("no carriage")344 return nil, errors.New("no carriage")
150 }345 }
151- probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())346+ probe, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, dial, daemonmux.DefaultMuxCeilings())
152 require.NoError(t, err)347 require.NoError(t, err)
153 348
154 observation, err := probe.ProbeLocal(context.Background())349 observation, err := probe.ProbeLocal(context.Background())
@@ -157,35 +352,149 @@ func TestBrokerLocalProbeFailsClosed(t *testing.T) {
157 require.Zero(t, observation.Identity)352 require.Zero(t, observation.Identity)
158 require.True(t, observation.Incarnation.IsZero())353 require.True(t, observation.Incarnation.IsZero())
159 require.Zero(t, observation.ProtocolVersion)354 require.Zero(t, observation.ProtocolVersion)
355+ require.False(t, observation.InventoryKnown)
160 require.Nil(t, observation.Sessions)356 require.Nil(t, observation.Sessions)
161 require.Equal(t, []string{binding.Route.Address()}, addresses)357 require.Equal(t, []string{binding.Route.Address()}, addresses)
162 358
163 t.Run("a nil carriage is refused", func(t *testing.T) {359 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) {
165 return nil, nil361 return nil, nil
166 }, daemonmux.DefaultMuxCeilings())362 }, daemonmux.DefaultMuxCeilings())
167 require.NoError(t, err)363 require.NoError(t, err)
168 observation, err := probe.ProbeLocal(context.Background())364 observation, err := probe.ProbeLocal(context.Background())
169 require.Error(t, err)365 require.Error(t, err)
170 require.Zero(t, observation.Identity)366 require.Zero(t, observation.Identity)
367+ require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
171 })368 })
172 369
173 t.Run("construction refuses a missing dialer", func(t *testing.T) {370 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())
175 require.Error(t, err)379 require.Error(t, err)
176 })380 })
177 }381 }
178 382
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+
179 // TestBrokerLocalObservationComposition proves the composition derives the480 // TestBrokerLocalObservationComposition proves the composition derives the
180 // local producer's authority from the validated configuration and refuses a481 // local producer's authority from the validated configuration and refuses a
181 // configuration that provisions no local binding.482 // configuration that provisions no local binding.
182 func TestBrokerLocalObservationComposition(t *testing.T) {483 func TestBrokerLocalObservationComposition(t *testing.T) {
183 config, binding := testLocalSandboxConfig(t)484 config, binding := testLocalSandboxConfig(t)
184- observation, err := brokerLocalObservation(config, nil)485+ connector := unreachableCarriageConnector(t)
486+ observation, err := brokerLocalObservation(config, brokerLocalTestEpoch, connector)
185 require.NoError(t, err)487 require.NoError(t, err)
186 require.Equal(t, binding.DisplayOrigin, observation.DisplayOrigin)488 require.Equal(t, binding.DisplayOrigin, observation.DisplayOrigin)
187 require.Equal(t, binding.Policy, observation.Policy)489 require.Equal(t, binding.Policy, observation.Policy)
188 require.NotNil(t, observation.Probe)490 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)
189 498
190 t.Run("no local binding provisions no producer", func(t *testing.T) {499 t.Run("no local binding provisions no producer", func(t *testing.T) {
191 root := filepath.Join(shortTempDir(t, "vevl"), "sandbox")500 root := filepath.Join(shortTempDir(t, "vevl"), "sandbox")
@@ -195,16 +504,24 @@ func TestBrokerLocalObservationComposition(t *testing.T) {
195 require.NoError(t, err)504 require.NoError(t, err)
196 config, err := brokerconfig.Load(layout)505 config, err := brokerconfig.Load(layout)
197 require.NoError(t, err)506 require.NoError(t, err)
198- _, err = brokerLocalObservation(config, nil)507+ _, err = brokerLocalObservation(config, brokerLocalTestEpoch, connector)
199 require.Error(t, err)508 require.Error(t, err)
200 })509 })
201 }510 }
202 511
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.
203 func TestOfflineRegistryConfigSelectsLocalObservationOnlyWhenProvisioned(t *testing.T) {519 func TestOfflineRegistryConfigSelectsLocalObservationOnlyWhenProvisioned(t *testing.T) {
204 localConfig, binding := testLocalSandboxConfig(t)520 localConfig, binding := testLocalSandboxConfig(t)
205- selected, err := offlineRegistryConfig(localConfig, nil)521+ selected, err := offlineRegistryConfig(localConfig, brokerLocalTestEpoch, unreachableCarriageConnector(t))
206 require.NoError(t, err)522 require.NoError(t, err)
207 require.False(t, selected.ObservationDisabled)523 require.False(t, selected.ObservationDisabled)
524+ require.Equal(t, broker.MembershipMutable, selected.MembershipMode, "a provisioned local binding always owns mutable membership")
208 require.NotNil(t, selected.Local)525 require.NotNil(t, selected.Local)
209 require.Equal(t, binding.DisplayOrigin, selected.Local.DisplayOrigin)526 require.Equal(t, binding.DisplayOrigin, selected.Local.DisplayOrigin)
210 require.Equal(t, binding.Policy, selected.Local.Policy)527 require.Equal(t, binding.Policy, selected.Local.Policy)
@@ -216,41 +533,249 @@ func TestOfflineRegistryConfigSelectsLocalObservationOnlyWhenProvisioned(t *test
216 require.NoError(t, err)533 require.NoError(t, err)
217 remoteOnly, err := brokerconfig.Load(layout)534 remoteOnly, err := brokerconfig.Load(layout)
218 require.NoError(t, err)535 require.NoError(t, err)
219- disabled, err := offlineRegistryConfig(remoteOnly, nil)536+ disabled, err := offlineRegistryConfig(remoteOnly, brokerLocalTestEpoch, unreachableCarriageConnector(t))
220 require.NoError(t, err)537 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")
222 require.Nil(t, disabled.Local)539 require.Nil(t, disabled.Local)
223 }540 }
224 541
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+
225 // TestBrokerLocalObservationRegistryPublishesLocalFirst proves the composition761 // TestBrokerLocalObservationRegistryPublishesLocalFirst proves the composition
226 // end to end: the registry observes the local daemon through the broker-owned762 // 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 local763+// probe and publishes it at index zero with the observed inventory, and the
228-// entry (the local observation is never durable).764+// durable store persists no local entry (the local observation is never
765+// durable).
229 func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) {766 func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) {
230- _, binding := testLocalSandboxConfig(t)767+ config, binding := testLocalSandboxConfig(t)
231 serverBinding := testServerBinding(t, binding)768 serverBinding := testServerBinding(t, binding)
769+ session := testLocalUpSession(domain.SessionLifecycleID{9}, "registry")
232 770
233- var dials atomic.Int32771+ daemon := newScriptedLocalDaemon(t, serverBinding, func(message protocol.ClientMessage) protocol.ServerMessage {
234- dial := func(ctx context.Context, _ string) (daemonmux.RawFramedTransport, error) {772+ request, ok := message.(protocol.CommandRequest)
235- dials.Add(1)773+ if !ok {
236- clientConn, serverConn := net.Pipe()774+ return nil
237- go func() {775+ }
238- defer serverConn.Close()776+ return protocol.CommandResult{RequestID: request.RequestID, Outcome: protocol.CommandSucceeded, Output: testLocalCatalogue(t, session)}
239- bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn))777+ })
240- if err != nil {778+ local, err := brokerLocalObservation(config, brokerLocalTestEpoch, testLocalConnector(t, daemon.dial()))
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())
254 require.NoError(t, err)779 require.NoError(t, err)
255 780
256 stateDir := filepath.Join(shortTempDir(t, "vevs"), "state")781 stateDir := filepath.Join(shortTempDir(t, "vevs"), "state")
@@ -259,9 +784,7 @@ func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) {
259 require.NoError(t, err)784 require.NoError(t, err)
260 t.Cleanup(func() { require.NoError(t, store.Close()) })785 t.Cleanup(func() { require.NoError(t, store.Close()) })
261 786
262- registry, err := broker.NewRegistryWithConfig(7, store, nil, clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)), broker.RegistryConfig{787+ registry, err := broker.NewRegistryWithConfig(brokerLocalTestEpoch, store, nil, clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)), broker.RegistryConfig{Local: local})
263- Local: &broker.LocalObservation{DisplayOrigin: binding.DisplayOrigin, Policy: binding.Policy, Probe: probe},
264- })
265 require.NoError(t, err)788 require.NoError(t, err)
266 789
267 ctx, cancel := context.WithCancel(context.Background())790 ctx, cancel := context.WithCancel(context.Background())
@@ -279,7 +802,10 @@ func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) {
279 require.Len(t, snapshot.Daemons, 1)802 require.Len(t, snapshot.Daemons, 1)
280 require.True(t, snapshot.Daemons[0].Local)803 require.True(t, snapshot.Daemons[0].Local)
281 require.Equal(t, binding.Identity, snapshot.Daemons[0].Identity)804 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())
283 809
284 // The durable store persists remote observations only: the local entry never810 // The durable store persists remote observations only: the local entry never
285 // reaches disk.811 // reaches disk.
@@ -289,11 +815,79 @@ func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) {
289 }, 3*time.Second, 2*time.Millisecond)815 }, 3*time.Second, 2*time.Millisecond)
290 stored, err := store.Load()816 stored, err := store.Load()
291 require.NoError(t, err)817 require.NoError(t, err)
292- for _, daemon := range stored.Daemons {818+ for _, entry := range stored.Daemons {
293- require.False(t, daemon.Local, "a local observation must never be durable")819+ require.False(t, entry.Local, "a local observation must never be durable")
294 }820 }
295 }821 }
296 822
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+
297 // TestBrokerLocalProbeRefusesNonLocalRoute proves the probe refuses a891 // TestBrokerLocalProbeRefusesNonLocalRoute proves the probe refuses a
298 // hand-built binding whose route is an ssh helper route: probing must never892 // hand-built binding whose route is an ssh helper route: probing must never
299 // start a child process, so only a Unix route this process dials directly is893 // start a child process, so only a Unix route this process dials directly is
@@ -317,7 +911,7 @@ func TestBrokerLocalProbeRefusesNonLocalRoute(t *testing.T) {
317 Policy: brokerTestPolicy(),911 Policy: brokerTestPolicy(),
318 Route: sshRoute,912 Route: sshRoute,
319 }913 }
320- _, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) {914+ _, err := newLocalRouteProbe(binding, brokerLocalTestEpoch, func(context.Context, ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
321 t.Fatal("an ssh route must never be dialed by the local probe")915 t.Fatal("an ssh route must never be dialed by the local probe")
322 return nil, nil916 return nil, nil
323 }, daemonmux.DefaultMuxCeilings())917 }, daemonmux.DefaultMuxCeilings())
@@ -334,3 +928,47 @@ func TestBrokerLocalProbeNilReceiverFailsClosed(t *testing.T) {
334 require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)928 require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
335 require.NotZero(t, observation.Availability, "availability is never zero")929 require.NotZero(t, observation.Availability, "availability is never zero")
336 }930 }
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 (
19 "github.com/bnema/vev/internal/adapters/ipc"19 "github.com/bnema/vev/internal/adapters/ipc"
20 "github.com/bnema/vev/internal/adapters/quic"20 "github.com/bnema/vev/internal/adapters/quic"
21 "github.com/bnema/vev/internal/adapters/sshstdio"21 "github.com/bnema/vev/internal/adapters/sshstdio"
22+ "github.com/bnema/vev/internal/ports"
22 "github.com/bnema/vev/internal/protocol/wire"23 "github.com/bnema/vev/internal/protocol/wire"
23 )24 )
24 25
@@ -30,9 +31,9 @@ import (
30 // QUIC bootstrap that pins a freshly minted ephemeral certificate. This file31 // QUIC bootstrap that pins a freshly minted ephemeral certificate. This file
31 // owns both halves.32 // owns both halves.
32 //33 //
33-// The broker side is a transport-selecting daemonmux.EndpointConnector. It34+// The broker side is a transport-selecting daemonmux.EndpointConnector. In
34-// receives only the opaque address the immutable resolver published, looks the35+// production it rechecks remote targets against committed registry records;
35-// route up in the configuration it was composed with, and dials that route. It36+// sandbox fixtures retain their immutable configured routes and trust inputs. It
36 // never authenticates the carriage itself: the daemonmux physical preamble37 // never authenticates the carriage itself: the daemonmux physical preamble
37 // verifies the daemon identity, incarnation, and policy the resolver already38 // verifies the daemon identity, incarnation, and policy the resolver already
38 // pinned. One caller-supplied setup context bounds bootstrap, pinned dial, and39 // pinned. One caller-supplied setup context bounds bootstrap, pinned dial, and
@@ -49,10 +50,11 @@ import (
49 // server, admits exactly one authenticated carriage, and bridges it to the50 // server, admits exactly one authenticated carriage, and bridges it to the
50 // same private Unix daemonmux carriage.51 // same private Unix daemonmux carriage.
51 //52 //
52-// A helper only ever bridges to the configured private Unix daemonmux53+// Each helper also accepts --production instead of --offline-root. That scope
53-// endpoint. It never starts a broker, an observer, or an ordinary daemon, never54+// derives the private carriage from this user's production runtime. Helpers
54-// dials the production daemon socket, and never fabricates a daemon55+// never start a broker or observer. Only an explicit if-needed authorization
55-// incarnation: the daemon on the other side of the carriage owns that value.56+// permits the shared daemon starter; existing-only observation never spawns.
57+// The daemon on the other side owns its identity and incarnation.
56 // The one-time QUIC token and nonce are minted per physical connection, travel58 // The one-time QUIC token and nonce are minted per physical connection, travel
57 // only inside the SSH channel and the encrypted stream, and never reach an59 // only inside the SSH channel and the encrypted stream, and never reach an
58 // address, a log, or an error. SSH host-key verification, noninteractive60 // address, a log, or an error. SSH host-key verification, noninteractive
@@ -93,20 +95,43 @@ const brokerMuxQUICDialTimeout = 15 * time.Second
93 // setup shutdown past this delay; the direct child is still killed and reaped.95 // setup shutdown past this delay; the direct child is still killed and reaped.
94 const brokerMuxWaitDelay = 2 * time.Second96 const brokerMuxWaitDelay = 2 * time.Second
95 97
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+
96 // brokerMuxOptions is the parsed hidden mux helper invocation.104 // brokerMuxOptions is the parsed hidden mux helper invocation.
97 type brokerMuxOptions struct {105 type brokerMuxOptions struct {
106+ production bool
98 offlineRoot string107 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
99 }112 }
100 113
101-// parseBrokerMuxArgs strictly parses `<hidden> --offline-root ABS`. Unknown or114+// parseBrokerMuxArgs strictly parses `<hidden> (--offline-root ABS | --production)
102-// duplicate flags, a missing value, and positionals are refused, exactly like115+// [--daemon-start existing-only|if-needed]`. Unknown or duplicate flags, a
103-// the foreground sandbox entry point.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.
104 func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, error) {120 func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, error) {
105- options := brokerMuxOptions{}121+ options := brokerMuxOptions{startMode: ports.BrokerDaemonExistingOnly}
106- rootSet := false122+ rootSet, startSet := false, false
107 for index := 0; index < len(args); {123 for index := 0; index < len(args); {
108 switch args[index] {124 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++
109 case "--offline-root":131 case "--offline-root":
132+ if options.production {
133+ return command{}, usagef("`%s` received conflicting scope", name)
134+ }
110 if rootSet {135 if rootSet {
111 return command{}, usagef("`%s` received duplicate --offline-root", name)136 return command{}, usagef("`%s` received duplicate --offline-root", name)
112 }137 }
@@ -116,6 +141,20 @@ func parseBrokerMuxArgs(name string, kind cmdKind, args []string) (command, erro
116 options.offlineRoot = args[index+1]141 options.offlineRoot = args[index+1]
117 rootSet = true142 rootSet = true
118 index += 2143 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
119 default:158 default:
120 if strings.HasPrefix(args[index], "-") {159 if strings.HasPrefix(args[index], "-") {
121 return command{}, usagef("unknown flag %q for `%s`", args[index], name)160 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
123 return command{}, usagef("`%s` does not accept positional arguments", name)162 return command{}, usagef("`%s` does not accept positional arguments", name)
124 }163 }
125 }164 }
126- if !rootSet {165+ if !rootSet && !options.production {
127- return command{}, usagef("`%s` requires --offline-root", name)166+ return command{}, usagef("`%s` requires --offline-root or --production", name)
128 }167 }
129 return command{kind: kind, brokerMux: options}, nil168 return command{kind: kind, brokerMux: options}, nil
130 }169 }
@@ -141,51 +180,134 @@ func loadMuxHelperConfig(offlineRoot string) (*brokerconfig.Config, error) {
141 }180 }
142 181
143 // brokerMuxConnector builds the transport-selecting daemonmux endpoint182 // brokerMuxConnector builds the transport-selecting daemonmux endpoint
144-// connector over one immutable configuration. The dial function receives only183+// connector over one immutable configuration. The dial function receives the
145-// the opaque address the resolver published and refuses any address the184+// whole resolved target the pool will authenticate against and refuses any
146-// configuration did not produce, so a request can never select a transport or185+// address the configuration did not produce, so a request can never select a
147-// an address of its own.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.
148 func brokerMuxConnector(config *brokerconfig.Config, log *slog.Logger) (*daemonmux.EndpointConnector, error) {190 func brokerMuxConnector(config *brokerconfig.Config, log *slog.Logger) (*daemonmux.EndpointConnector, error) {
149 if config == nil {191 if config == nil {
150 return nil, errors.New("vev: broker mux connector requires a configuration")192 return nil, errors.New("vev: broker mux connector requires a configuration")
151 }193 }
152- dial := func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) {194+ dial := func(ctx context.Context, target ports.BrokerDialTarget) (daemonmux.RawFramedTransport, error) {
153- route, ok := config.RouteByAddress(address)195+ route, ok := config.RouteByAddress(target.Address)
154 if !ok {196 if !ok {
155 return nil, fmt.Errorf("vev: broker mux: unknown route address")197 return nil, fmt.Errorf("vev: broker mux: unknown route address")
156 }198 }
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)
158 }203 }
159 return daemonmux.NewEndpointConnector(dial, daemonmux.DefaultMuxCeilings())204 return daemonmux.NewEndpointConnector(dial, daemonmux.DefaultMuxCeilings())
160 }205 }
161 206
162-// dialBrokerRoute dials one provisioned route as an already-authenticated raw207+// brokerDynamicMuxConnector keeps the configured local carriage, but remote
163-// framed carriage. Authentication, identity, and policy remain the daemonmux208+// targets must still match committed registry authority at dial time.
164-// connector's concern; this function only selects the transport.209+func brokerDynamicMuxConnector(config *brokerconfig.Config, routes *brokerRoutes, log *slog.Logger) (*daemonmux.EndpointConnector, error) {
165-func dialBrokerRoute(ctx context.Context, route brokerconfig.Route, log *slog.Logger) (daemonmux.RawFramedTransport, 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) {
166 switch route.Kind() {265 switch route.Kind() {
167 case brokerconfig.RouteUnix:266 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+ }
168 return ipc.DialMuxContext(ctx, route.Path())273 return ipc.DialMuxContext(ctx, route.Path())
169 case brokerconfig.RouteSSHStdio:274 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)
171 case brokerconfig.RouteSSHQUIC:280 case brokerconfig.RouteSSHQUIC:
172- return dialBrokerQUICRoute(ctx, route, log)281+ return dialBrokerQUICRoute(ctx, route, target.StartMode, log)
173 default:282 default:
174 return nil, fmt.Errorf("vev: broker mux: unsupported route kind %q", route.Kind())283 return nil, fmt.Errorf("vev: broker mux: unsupported route kind %q", route.Kind())
175 }284 }
176 }285 }
177 286
178-// sshMuxCommandSpec builds the local ssh argv for one provisioned route. It287+// sshMuxCommandSpec builds the local ssh argv for one provisioned route and one
179-// reuses the mux carriage's own command construction (the option terminator,288+// start authorization. It reuses the mux carriage's own command construction
180-// the POSIX-quoted remote argv, and `-T` for no remote PTY) and splices in only289+// (the option terminator, the POSIX-quoted remote argv, and `-T` for no remote
181-// the explicit trust inputs the operator provisioned. It never disables290+// PTY), splices in only the explicit trust inputs the operator provisioned, and
182-// host-key verification, never forces a non-interactive auth bypass, and never291+// appends the closed `--daemon-start existing-only|if-needed` word so the remote
183-// requests a PTY.292+// helper reads the authorization the broker propagated instead of defaulting.
184-func sshMuxCommandSpec(route brokerconfig.Route) sshstdio.CommandSpec {293+// It never disables host-key verification, never forces a non-interactive auth
185- spec := sshstdio.BuildCommandForMux(route.Target(), route.Argv()...)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...)
186 options := sshTrustOptions(route)308 options := sshTrustOptions(route)
187 if len(options) == 0 || len(spec.Args) < 2 {309 if len(options) == 0 || len(spec.Args) < 2 {
188- return spec310+ return spec, nil
189 }311 }
190 // BuildCommandForMux returns ["-T", "--", target, remote]; insert options312 // BuildCommandForMux returns ["-T", "--", target, remote]; insert options
191 // after `-T` so they stay ssh options and the option terminator still313 // after `-T` so they stay ssh options and the option terminator still
@@ -194,7 +316,7 @@ func sshMuxCommandSpec(route brokerconfig.Route) sshstdio.CommandSpec {
194 args = append(args, spec.Args[0])316 args = append(args, spec.Args[0])
195 args = append(args, options...)317 args = append(args, options...)
196 args = append(args, spec.Args[1:]...)318 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
198 }320 }
199 321
200 // sshTrustOptions renders the provisioned trust inputs as ssh options. A322 // sshTrustOptions renders the provisioned trust inputs as ssh options. A
@@ -229,8 +351,11 @@ func sshTrustOptions(route brokerconfig.Route) []string {
229 // within the bound and can never pin pool setup. The captured stderr sink is351 // within the bound and can never pin pool setup. The captured stderr sink is
230 // read only after that kill and reap, and the sink is itself synchronized, so352 // read only after that kill and reap, and the sink is itself synchronized, so
231 // the os/exec copy goroutine can never race the diagnostic.353 // 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) {354+func dialBrokerQUICRoute(ctx context.Context, route brokerconfig.Route, mode ports.BrokerDaemonStartMode, log *slog.Logger) (wire.BoundedTransport, error) {
233- spec := sshMuxCommandSpec(route)355+ spec, err := sshMuxCommandSpec(route, mode)
356+ if err != nil {
357+ return nil, err
358+ }
234 stderr := sshstdio.NewDiagnosticSink(0)359 stderr := sshstdio.NewDiagnosticSink(0)
235 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)360 cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
236 cmd.Stderr = stderr361 cmd.Stderr = stderr
@@ -377,21 +502,19 @@ func brokerQUICUnavailable(action string, err error) error {
377 }502 }
378 503
379 // runBrokerMuxStdioCommand bridges the process' own stdio to the single504 // 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.
381 func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) error {507 func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) error {
382- config, err := loadMuxHelperConfig(options.offlineRoot)508+ raw, err := dialBrokerMuxHelper(ctx, options)
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())
391 if err != nil {509 if err != nil {
392 return fmt.Errorf("vev: broker mux stdio: dial daemonmux carriage: %w", err)510 return fmt.Errorf("vev: broker mux stdio: dial daemonmux carriage: %w", err)
393 }511 }
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)
395 }518 }
396 519
397 // runBrokerMuxQUICBootstrapCommand starts one detached `_broker-mux-quic-proxy`,520 // runBrokerMuxQUICBootstrapCommand starts one detached `_broker-mux-quic-proxy`,
@@ -401,8 +524,10 @@ func runBrokerMuxStdioCommand(ctx context.Context, options brokerMuxOptions) err
401 func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOptions) error {524 func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOptions) error {
402 // Validate the sandbox before spawning anything, so a bad root or config525 // Validate the sandbox before spawning anything, so a bad root or config
403 // fails without a readiness line and without a child.526 // fails without a readiness line and without a child.
404- if _, err := loadMuxHelperConfig(options.offlineRoot); err != nil {527+ if !options.production {
405- return err528+ if _, err := loadMuxHelperConfig(options.offlineRoot); err != nil {
529+ return err
530+ }
406 }531 }
407 exe, err := os.Executable()532 exe, err := os.Executable()
408 if err != nil {533 if err != nil {
@@ -420,7 +545,19 @@ func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOpti
420 }545 }
421 defer func() { _ = devNull.Close() }()546 defer func() { _ = devNull.Close() }()
422 547
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...)
424 // The proxy writes its readiness through the pipe and its diagnostics561 // The proxy writes its readiness through the pipe and its diagnostics
425 // through stderr; stdio is otherwise detached so the SSH bootstrap channel562 // through stderr; stdio is otherwise detached so the SSH bootstrap channel
426 // can close while the detached proxy owns the carriage.563 // can close while the detached proxy owns the carriage.
@@ -464,14 +601,6 @@ func runBrokerMuxQUICBootstrapCommand(ctx context.Context, options brokerMuxOpti
464 // expiry, or on setup failure, so it can never outlive its advertised601 // expiry, or on setup failure, so it can never outlive its advertised
465 // credential or serve a second physical connection.602 // credential or serve a second physical connection.
466 func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions) error {603 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- }
475 server, readiness, err := quic.NewServer()604 server, readiness, err := quic.NewServer()
476 if err != nil {605 if err != nil {
477 return err606 return err
@@ -489,11 +618,37 @@ func runBrokerMuxQUICProxyCommand(ctx context.Context, options brokerMuxOptions)
489 return err618 return err
490 }619 }
491 defer func() { _ = transport.Close() }()620 defer func() { _ = transport.Close() }()
492- raw, err := ipc.DialMuxContext(ctx, route.Path())621+ raw, err := dialBrokerMuxHelper(ctx, options)
493 if err != nil {622 if err != nil {
494 return fmt.Errorf("vev: broker mux quic proxy: dial daemonmux carriage: %w", err)623 return fmt.Errorf("vev: broker mux quic proxy: dial daemonmux carriage: %w", err)
495 }624 }
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)
497 }652 }
498 653
499 // bridgeMuxTransports copies raw frames both ways until either carriage ends or654 // 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 {
161 // resolver publishes, so a test can inspect a parsed route without the161 // resolver publishes, so a test can inspect a parsed route without the
162 // unexported brokerconfig fields.162 // unexported brokerconfig fields.
163 func loadTestRoute(t *testing.T, route any) brokerconfig.Route {163 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) {
164 t.Helper()173 t.Helper()
165 root := writeMuxRoot(t, route)174 root := writeMuxRoot(t, route)
166 layout, err := offlineLayout(root)175 layout, err := offlineLayout(root)
167 require.NoError(t, err)176 require.NoError(t, err)
168 config, err := brokerconfig.Load(layout)177 config, err := brokerconfig.Load(layout)
169 require.NoError(t, err)178 require.NoError(t, err)
170- resolved, err := config.Resolver().Resolve(context.Background(), ports.BrokerOpenStreamRequest{179+ resolved, err := config.Resolver().ResolveDialTarget(context.Background(), ports.BrokerOpenStreamRequest{
171 Purpose: ports.BrokerStreamControl,180 Purpose: ports.BrokerStreamControl,
181+ Stream: 1,
172 Endpoint: brokerTestEndpoint,182 Endpoint: brokerTestEndpoint,
173 Registration: brokerTestRegistration(),183 Registration: brokerTestRegistration(),
174 Policy: brokerTestPolicy(),184 Policy: brokerTestPolicy(),
185+ StartMode: ports.BrokerDaemonStartIfNeeded,
175 })186 })
176 require.NoError(t, err)187 require.NoError(t, err)
177 parsed, ok := config.RouteByAddress(resolved.Address)188 parsed, ok := config.RouteByAddress(resolved.Address)
178 require.True(t, ok)189 require.True(t, ok)
179- return parsed190+ 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)
180 }218 }
181 219
182 // TestParseBrokerMuxArgs pins the strict hidden-helper argument contract.220 // TestParseBrokerMuxArgs pins the strict hidden-helper argument contract.
@@ -187,12 +225,17 @@ func TestParseBrokerMuxArgs(t *testing.T) {
187 wantErr string225 wantErr string
188 want brokerMuxOptions226 want brokerMuxOptions
189 }{227 }{
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}},
191 {name: "missing root", args: nil, wantErr: "requires --offline-root"},231 {name: "missing root", args: nil, wantErr: "requires --offline-root"},
192 {name: "empty root", args: []string{"--offline-root", ""}, wantErr: "requires a path"},232 {name: "empty root", args: []string{"--offline-root", ""}, wantErr: "requires a path"},
193 {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate"},233 {name: "duplicate root", args: []string{"--offline-root", "/a", "--offline-root", "/b"}, wantErr: "duplicate"},
194 {name: "unknown flag", args: []string{"--idle-grace", "1m"}, wantErr: "unknown flag"},234 {name: "unknown flag", args: []string{"--idle-grace", "1m"}, wantErr: "unknown flag"},
195 {name: "positional", args: []string{"--offline-root", "/a", "extra"}, wantErr: "positional"},235 {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"},
196 }239 }
197 for _, tt := range tests {240 for _, tt := range tests {
198 t.Run(tt.name, func(t *testing.T) {241 t.Run(tt.name, func(t *testing.T) {
@@ -203,7 +246,7 @@ func TestParseBrokerMuxArgs(t *testing.T) {
203 return246 return
204 }247 }
205 require.NoError(t, err)248 require.NoError(t, err)
206- require.Equal(t, brokerMuxOptions{offlineRoot: tt.want.offlineRoot}, command.brokerMux)249+ require.Equal(t, tt.want, command.brokerMux)
207 })250 })
208 }251 }
209 }252 }
@@ -245,7 +288,8 @@ func TestSSHMuxCommandSpecPreservesTrustAndNoPTY(t *testing.T) {
245 "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", "/srv/remote"},288 "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", "/srv/remote"},
246 "trust": map[string]any{"knownHostsFile": "/etc/ssh/known_hosts", "connectTimeout": "30s"},289 "trust": map[string]any{"knownHostsFile": "/etc/ssh/known_hosts", "connectTimeout": "30s"},
247 })290 })
248- spec := sshMuxCommandSpec(route)291+ spec, err := sshMuxCommandSpec(route, ports.BrokerDaemonStartIfNeeded)
292+ require.NoError(t, err)
249 require.Equal(t, "ssh", spec.Path)293 require.Equal(t, "ssh", spec.Path)
250 require.Equal(t, "-T", spec.Args[0])294 require.Equal(t, "-T", spec.Args[0])
251 require.Contains(t, spec.Args, "--")295 require.Contains(t, spec.Args, "--")
@@ -254,6 +298,7 @@ func TestSSHMuxCommandSpecPreservesTrustAndNoPTY(t *testing.T) {
254 joined := strings.Join(spec.Args, " ")298 joined := strings.Join(spec.Args, " ")
255 require.Contains(t, joined, "-- user@host:2222 ")299 require.Contains(t, joined, "-- user@host:2222 ")
256 require.Contains(t, joined, brokerMuxStdioCommand)300 require.Contains(t, joined, brokerMuxStdioCommand)
301+ require.Contains(t, joined, "'"+brokerDaemonStartArg+"' 'if-needed'")
257 require.NotContains(t, joined, "StrictHostKeyChecking")302 require.NotContains(t, joined, "StrictHostKeyChecking")
258 require.NotContains(t, joined, "BatchMode")303 require.NotContains(t, joined, "BatchMode")
259 require.NotContains(t, joined, "ProxyCommand")304 require.NotContains(t, joined, "ProxyCommand")
@@ -270,9 +315,25 @@ func TestSSHMuxCommandSpecPreservesTrustAndNoPTY(t *testing.T) {
270 "target": "host",315 "target": "host",
271 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},316 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
272 })317 })
273- plainSpec := sshMuxCommandSpec(plain)318+ plainSpec, err := sshMuxCommandSpec(plain, ports.BrokerDaemonExistingOnly)
319+ require.NoError(t, err)
274 require.Equal(t, []string{"-T", "--"}, append([]string(nil), plainSpec.Args[:2]...))320 require.Equal(t, []string{"-T", "--"}, append([]string(nil), plainSpec.Args[:2]...))
275 require.NotContains(t, plainSpec.Args, "-o")321 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)
276 }337 }
277 338
278 // TestSSHMuxCommandSpecTargetQuotesSpacesInsideArgv proves a remote argv word339 // TestSSHMuxCommandSpecTargetQuotesSpacesInsideArgv proves a remote argv word
@@ -284,8 +345,12 @@ func TestSSHMuxCommandSpecTargetQuotesSpacesInsideArgv(t *testing.T) {
284 "target": "host",345 "target": "host",
285 "argv": []any{"vev", "_broker-mux-stdio", "--offline-root", "/srv/with space"},346 "argv": []any{"vev", "_broker-mux-stdio", "--offline-root", "/srv/with space"},
286 })347 })
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.
288 require.Contains(t, spec.Args[len(spec.Args)-1], `'/srv/with space'`)352 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'")
289 }354 }
290 355
291 // TestBrokerQUICPeerAddr pins the host-independent address composition.356 // TestBrokerQUICPeerAddr pins the host-independent address composition.
@@ -327,11 +392,7 @@ func TestBrokerMuxConnectorRejectsUnknownAddress(t *testing.T) {
327 connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil)))392 connector, err := brokerMuxConnector(config, slog.New(slog.NewTextHandler(io.Discard, nil)))
328 require.NoError(t, err)393 require.NoError(t, err)
329 394
330- _, err = connector.Connect(context.Background(), ports.BrokerResolvedEndpoint{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}})
331- Identity: brokerTestIdentity,
332- Policy: brokerTestPolicy(),
333- Address: "offline-route-00000000000000000000000000000000",
334- })
335 require.Error(t, err)396 require.Error(t, err)
336 var typed ports.BrokerError397 var typed ports.BrokerError
337 require.ErrorAs(t, err, &typed)398 require.ErrorAs(t, err, &typed)
@@ -416,14 +477,14 @@ func TestBrokerMuxQUICRejectsBadPinWithoutLeakingSecret(t *testing.T) {
416 tampered.Fingerprint = strings.Repeat("00", 32)477 tampered.Fingerprint = strings.Repeat("00", 32)
417 t.Setenv(fakeSSHReadyEnv, string(mustJSON(t, tampered)))478 t.Setenv(fakeSSHReadyEnv, string(mustJSON(t, tampered)))
418 479
419- route := loadTestRoute(t, map[string]any{480+ route, target := loadTestRouteTarget(t, map[string]any{
420 "kind": "ssh-quic",481 "kind": "ssh-quic",
421 "target": "127.0.0.1",482 "target": "127.0.0.1",
422 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},483 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
423 })484 })
424 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)485 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
425 defer cancel()486 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)))
427 require.Error(t, err)488 require.Error(t, err)
428 requireBrokerErrorHasNoReadiness(t, err, readiness)489 requireBrokerErrorHasNoReadiness(t, err, readiness)
429 }490 }
@@ -446,12 +507,12 @@ func TestBrokerMuxQUICRejectsExpiredReadiness(t *testing.T) {
446 }507 }
447 t.Setenv(fakeSSHReadyEnv, string(mustJSON(t, readiness)))508 t.Setenv(fakeSSHReadyEnv, string(mustJSON(t, readiness)))
448 509
449- route := loadTestRoute(t, map[string]any{510+ route, target := loadTestRouteTarget(t, map[string]any{
450 "kind": "ssh-quic",511 "kind": "ssh-quic",
451 "target": "127.0.0.1",512 "target": "127.0.0.1",
452 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},513 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
453 })514 })
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)))
455 require.Error(t, err)516 require.Error(t, err)
456 require.NotContains(t, err.Error(), token)517 require.NotContains(t, err.Error(), token)
457 require.NotContains(t, err.Error(), nonce)518 require.NotContains(t, err.Error(), nonce)
@@ -466,7 +527,7 @@ func TestBrokerMuxDialCancellationReapsBootstrap(t *testing.T) {
466 pidFile := filepath.Join(t.TempDir(), "ssh.pid")527 pidFile := filepath.Join(t.TempDir(), "ssh.pid")
467 t.Setenv(fakeSSHPIDEnv, pidFile)528 t.Setenv(fakeSSHPIDEnv, pidFile)
468 529
469- route := loadTestRoute(t, map[string]any{530+ route, target := loadTestRouteTarget(t, map[string]any{
470 "kind": "ssh-quic",531 "kind": "ssh-quic",
471 "target": "127.0.0.1",532 "target": "127.0.0.1",
472 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},533 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
@@ -474,7 +535,7 @@ func TestBrokerMuxDialCancellationReapsBootstrap(t *testing.T) {
474 ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)535 ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
475 defer cancel()536 defer cancel()
476 start := time.Now()537 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)))
478 require.Error(t, err)539 require.Error(t, err)
479 require.Less(t, time.Since(start), 3*time.Second, "cancellation must return promptly")540 require.Less(t, time.Since(start), 3*time.Second, "cancellation must return promptly")
480 541
@@ -494,7 +555,7 @@ func TestBrokerMuxDialFloodedStderrIsReapedAndSanitized(t *testing.T) {
494 pidFile := filepath.Join(t.TempDir(), "ssh.pid")555 pidFile := filepath.Join(t.TempDir(), "ssh.pid")
495 t.Setenv(fakeSSHPIDEnv, pidFile)556 t.Setenv(fakeSSHPIDEnv, pidFile)
496 557
497- route := loadTestRoute(t, map[string]any{558+ route, target := loadTestRouteTarget(t, map[string]any{
498 "kind": "ssh-quic",559 "kind": "ssh-quic",
499 "target": "127.0.0.1",560 "target": "127.0.0.1",
500 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},561 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
@@ -504,7 +565,7 @@ func TestBrokerMuxDialFloodedStderrIsReapedAndSanitized(t *testing.T) {
504 logger := slog.New(slog.NewJSONHandler(&logs, nil))565 logger := slog.New(slog.NewJSONHandler(&logs, nil))
505 ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)566 ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
506 defer cancel()567 defer cancel()
507- _, err := dialBrokerRoute(ctx, route, logger)568+ _, err := dialBrokerRoute(ctx, route, target, logger)
508 require.Error(t, err)569 require.Error(t, err)
509 require.NotContains(t, err.Error(), "flood", "bootstrap stderr must never reach the error")570 require.NotContains(t, err.Error(), "flood", "bootstrap stderr must never reach the error")
510 571
@@ -535,13 +596,13 @@ func TestBrokerMuxDialStalledBootstrapUsesSetupTimeout(t *testing.T) {
535 brokerMuxBootstrapReadyTimeout = 250 * time.Millisecond596 brokerMuxBootstrapReadyTimeout = 250 * time.Millisecond
536 t.Cleanup(func() { brokerMuxBootstrapReadyTimeout = restore })597 t.Cleanup(func() { brokerMuxBootstrapReadyTimeout = restore })
537 598
538- route := loadTestRoute(t, map[string]any{599+ route, target := loadTestRouteTarget(t, map[string]any{
539 "kind": "ssh-quic",600 "kind": "ssh-quic",
540 "target": "127.0.0.1",601 "target": "127.0.0.1",
541 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},602 "argv": []any{"vev", brokerMuxQUICBootstrapCommand, "--offline-root", "/srv/remote"},
542 })603 })
543 start := time.Now()604 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)))
545 elapsed := time.Since(start)606 elapsed := time.Since(start)
546 require.Error(t, err)607 require.Error(t, err)
547 require.Contains(t, err.Error(), "read bootstrap readiness")608 require.Contains(t, err.Error(), "read bootstrap readiness")
@@ -575,7 +636,7 @@ func startMuxBrokerServe(t *testing.T, localRoot string) (string, func()) {
575 deps, ready := testBrokerServeDeps(newSandboxClock())636 deps, ready := testBrokerServeDeps(newSandboxClock())
576 ctx, cancel := context.WithCancel(context.Background())637 ctx, cancel := context.WithCancel(context.Background())
577 done := runSandbox(ctx, brokerServeOptions{offlineRoot: localRoot}, deps)638 done := runSandbox(ctx, brokerServeOptions{offlineRoot: localRoot}, deps)
578- socketPath := awaitSandboxReady(t, ready)639+ socketPath := awaitSandboxReady(t, ready, done)
579 var once bool640 var once bool
580 stop := func() {641 stop := func() {
581 if once {642 if once {
@@ -597,11 +658,15 @@ func requireBrokerPing(t *testing.T, socketPath string) {
597 require.NoError(t, err)658 require.NoError(t, err)
598 defer func() { _ = service.Close() }()659 defer func() { _ = service.Close() }()
599 660
661+ streamID, err := service.NextStreamID()
662+ require.NoError(t, err)
600 stream, err := service.OpenStream(clientCtx, ports.BrokerOpenStreamRequest{663 stream, err := service.OpenStream(clientCtx, ports.BrokerOpenStreamRequest{
601 Purpose: ports.BrokerStreamControl,664 Purpose: ports.BrokerStreamControl,
665+ Stream: streamID,
602 Endpoint: brokerTestEndpoint,666 Endpoint: brokerTestEndpoint,
603 Registration: brokerTestRegistration(),667 Registration: brokerTestRegistration(),
604 Policy: brokerTestPolicy(),668 Policy: brokerTestPolicy(),
669+ StartMode: ports.BrokerDaemonStartIfNeeded,
605 })670 })
606 require.NoError(t, err)671 require.NoError(t, err)
607 defer func() { _ = stream.Close() }()672 defer func() { _ = stream.Close() }()
M internal/app/broker_offline_status.go +25 -5
@@ -59,7 +59,8 @@ import (
59 const (59 const (
60 // brokerLauncherCommand is the hidden intermediate that starts one detached60 // brokerLauncherCommand is the hidden intermediate that starts one detached
61 // `_broker-serve` and exits.61 // `_broker-serve` and exits.
62- brokerLauncherCommand = "_broker-launcher"62+ brokerLauncherCommand = "_broker-launcher"
63+ productionBrokerLauncherCommand = "_broker-production-launcher"
63 // brokerStatusCommand is the hidden broker status probe.64 // brokerStatusCommand is the hidden broker status probe.
64 brokerStatusCommand = "_broker-status"65 brokerStatusCommand = "_broker-status"
65 )66 )
@@ -160,6 +161,13 @@ func defaultBrokerStatusDeps() brokerStatusDeps {
160 // parseBrokerLauncherArgs strictly parses161 // parseBrokerLauncherArgs strictly parses
161 // `_broker-launcher --offline-root ABS [--idle-grace DURATION]`. Unknown flags,162 // `_broker-launcher --offline-root ABS [--idle-grace DURATION]`. Unknown flags,
162 // duplicate flags, positionals, and non-positive durations are refused.163 // 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+
163 func parseBrokerLauncherArgs(args []string) (command, error) {171 func parseBrokerLauncherArgs(args []string) (command, error) {
164 var options brokerLauncherOptions172 var options brokerLauncherOptions
165 var seenRoot, seenGrace bool173 var seenRoot, seenGrace bool
@@ -261,6 +269,10 @@ func parseBrokerStatusArgs(args []string) (command, error) {
261 269
262 // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary270 // runBrokerLauncherCommand runs the hidden launcher. It changes no ordinary
263 // command and never runs in production flows.271 // command and never runs in production flows.
272+func runProductionBrokerLauncherCommand(context.Context) error {
273+ return startDetachedBrokerServe([]string{productionBrokerServeCommand})
274+}
275+
264 func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error {276 func runBrokerLauncherCommand(_ context.Context, options brokerLauncherOptions) error {
265 return runBrokerLauncher(options)277 return runBrokerLauncher(options)
266 }278 }
@@ -326,7 +338,19 @@ func startDetachedBrokerServe(args []string) error {
326 // signal kills a launcher that is still waiting, while killing the launcher338 // signal kills a launcher that is still waiting, while killing the launcher
327 // after it has started the detached `_broker-serve` does not touch that serve --339 // after it has started the detached `_broker-serve` does not touch that serve --
328 // it is in its own session and was released, so it survives.340 // 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+
329 func spawnBrokerLauncher(ctx context.Context, root string, grace time.Duration, graceSet bool) error {345 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 {
330 exePath, err := selfExePath()354 exePath, err := selfExePath()
331 if err != nil {355 if err != nil {
332 return fmt.Errorf("vev: resolving executable path: %w", err)356 return fmt.Errorf("vev: resolving executable path: %w", err)
@@ -337,10 +361,6 @@ func spawnBrokerLauncher(ctx context.Context, root string, grace time.Duration,
337 }361 }
338 defer func() { _ = devNull.Close() }()362 defer func() { _ = devNull.Close() }()
339 363
340- args := []string{brokerLauncherCommand, "--offline-root", root}
341- if graceSet {
342- args = append(args, "--idle-grace", grace.String())
343- }
344 var stderr bytes.Buffer364 var stderr bytes.Buffer
345 cmd := exec.CommandContext(ctx, exePath, args...)365 cmd := exec.CommandContext(ctx, exePath, args...)
346 cmd.Env = withoutPerformanceTraceEnv(os.Environ())366 cmd.Env = withoutPerformanceTraceEnv(os.Environ())
M internal/app/broker_offline_status_test.go +3 -4
@@ -296,7 +296,7 @@ func TestBrokerStatusDialOnlyReadyAgainstLiveSandbox(t *testing.T) {
296 ctx, cancel := context.WithCancel(context.Background())296 ctx, cancel := context.WithCancel(context.Background())
297 defer cancel()297 defer cancel()
298 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, serveDeps)298 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, serveDeps)
299- awaitSandboxReady(t, ready)299+ awaitSandboxReady(t, ready, done)
300 300
301 deps := defaultBrokerStatusDeps()301 deps := defaultBrokerStatusDeps()
302 var out bytes.Buffer302 var out bytes.Buffer
@@ -306,9 +306,8 @@ func TestBrokerStatusDialOnlyReadyAgainstLiveSandbox(t *testing.T) {
306 require.Equal(t, "ready", report.Status)306 require.Equal(t, "ready", report.Status)
307 require.Positive(t, report.Epoch)307 require.Positive(t, report.Epoch)
308 require.Positive(t, report.Revision)308 require.Positive(t, report.Revision)
309- // The sandbox registry restores from its own empty durable store; the309+ // Provisioned endpoints seed durable broker membership before serving.
310- // provisioned endpoints feed the resolver, not the host inventory.310+ require.Equal(t, 1, report.HostCount)
311- require.Zero(t, report.HostCount)
312 311
313 awaitSandboxCancel(t, cancel, done)312 awaitSandboxCancel(t, cancel, done)
314 requireProductionUntouched(t, prodRuntime, prodState)313 requireProductionUntouched(t, prodRuntime, prodState)
M internal/app/broker_offline_test.go +15 -8
@@ -309,7 +309,7 @@ func TestBrokerServeRoundTrip(t *testing.T) {
309 defer cancel()309 defer cancel()
310 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)310 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)
311 311
312- socketPath := awaitSandboxReady(t, ready)312+ socketPath := awaitSandboxReady(t, ready, done)
313 313
314 clientCtx, clientCancel := context.WithTimeout(context.Background(), brokerTestWait)314 clientCtx, clientCancel := context.WithTimeout(context.Background(), brokerTestWait)
315 defer clientCancel()315 defer clientCancel()
@@ -317,11 +317,15 @@ func TestBrokerServeRoundTrip(t *testing.T) {
317 require.NoError(t, err)317 require.NoError(t, err)
318 defer func() { _ = service.Close() }()318 defer func() { _ = service.Close() }()
319 319
320+ streamID, err := service.NextStreamID()
321+ require.NoError(t, err)
320 stream, err := service.OpenStream(clientCtx, ports.BrokerOpenStreamRequest{322 stream, err := service.OpenStream(clientCtx, ports.BrokerOpenStreamRequest{
321 Purpose: ports.BrokerStreamControl,323 Purpose: ports.BrokerStreamControl,
324+ Stream: streamID,
322 Endpoint: brokerTestEndpoint,325 Endpoint: brokerTestEndpoint,
323 Registration: brokerTestRegistration(),326 Registration: brokerTestRegistration(),
324 Policy: policy,327 Policy: policy,
328+ StartMode: ports.BrokerDaemonStartIfNeeded,
325 })329 })
326 require.NoError(t, err)330 require.NoError(t, err)
327 defer func() { _ = stream.Close() }()331 defer func() { _ = stream.Close() }()
@@ -355,14 +359,17 @@ func TestBrokerServeRoundTrip(t *testing.T) {
355 359
356 // awaitSandboxReady waits for the sandbox readiness hook and returns the360 // awaitSandboxReady waits for the sandbox readiness hook and returns the
357 // reported socket path.361 // 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 {
359 t.Helper()363 t.Helper()
360 select {364 select {
361 case socketPath := <-ready:365 case socketPath := <-ready:
362 require.NotEmpty(t, socketPath)366 require.NotEmpty(t, socketPath)
363 return socketPath367 return socketPath
368+ case err := <-done:
369+ t.Fatalf("sandbox exited before readiness: %v", err)
370+ return ""
364 case <-time.After(brokerTestWait):371 case <-time.After(brokerTestWait):
365- t.Fatal("sandbox never reported readiness")372+ t.Fatal("sandbox never reported readiness and did not exit")
366 return ""373 return ""
367 }374 }
368 }375 }
@@ -403,7 +410,7 @@ func TestBrokerServeIdleShutdownOnEmptyConfig(t *testing.T) {
403 defer cancel()410 defer cancel()
404 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)411 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)
405 412
406- socketPath := awaitSandboxReady(t, ready)413+ socketPath := awaitSandboxReady(t, ready, done)
407 waitForSandboxShutdown(t, clk, done)414 waitForSandboxShutdown(t, clk, done)
408 415
409 requireSocketRemoved(t, socketPath)416 requireSocketRemoved(t, socketPath)
@@ -430,7 +437,7 @@ func TestBrokerServeCancelCleanup(t *testing.T) {
430 defer cancel()437 defer cancel()
431 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)438 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)
432 439
433- socketPath := awaitSandboxReady(t, ready)440+ socketPath := awaitSandboxReady(t, ready, done)
434 awaitSandboxCancel(t, cancel, done)441 awaitSandboxCancel(t, cancel, done)
435 442
436 requireSocketRemoved(t, socketPath)443 requireSocketRemoved(t, socketPath)
@@ -474,7 +481,7 @@ func TestBrokerServeShutsDownOnUnexpectedAcceptFailure(t *testing.T) {
474 defer cancel()481 defer cancel()
475 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)482 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)
476 483
477- awaitSandboxReady(t, ready)484+ awaitSandboxReady(t, ready, done)
478 select {485 select {
479 case err := <-done:486 case err := <-done:
480 require.NoError(t, err)487 require.NoError(t, err)
@@ -496,7 +503,7 @@ func TestBrokerServeRefusesDuplicateOwnership(t *testing.T) {
496 ctx, cancel := context.WithCancel(context.Background())503 ctx, cancel := context.WithCancel(context.Background())
497 defer cancel()504 defer cancel()
498 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)505 done := runSandbox(ctx, brokerServeOptions{offlineRoot: root}, deps)
499- socketPath := awaitSandboxReady(t, ready)506+ socketPath := awaitSandboxReady(t, ready, done)
500 507
501 // A second foreground owner fails fast on the lifetime lock.508 // A second foreground owner fails fast on the lifetime lock.
502 secondDeps, _ := testBrokerServeDeps(newSandboxClock())509 secondDeps, _ := testBrokerServeDeps(newSandboxClock())
@@ -554,7 +561,7 @@ func newBrokerMuxFixture(t *testing.T, policy ports.BrokerPolicy) *brokerMuxFixt
554 for i := range incarnation {561 for i := range incarnation {
555 incarnation[i] = byte(0x10 + i)562 incarnation[i] = byte(0x10 + i)
556 }563 }
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}})
558 require.NoError(t, err)565 require.NoError(t, err)
559 supervisor, err := daemonmux.NewServerSupervisor(aggregate, binding, daemonmux.DefaultMuxCeilings(), 8)566 supervisor, err := daemonmux.NewServerSupervisor(aggregate, binding, daemonmux.DefaultMuxCeilings(), 8)
560 require.NoError(t, err)567 require.NoError(t, err)
M internal/app/broker_production.go +24 -0
@@ -6,6 +6,9 @@ import (
6 "github.com/bnema/vev/internal/adapters/brokerconfig"6 "github.com/bnema/vev/internal/adapters/brokerconfig"
7 "github.com/bnema/vev/internal/adapters/ipc"7 "github.com/bnema/vev/internal/adapters/ipc"
8 "github.com/bnema/vev/internal/platform"8 "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"
9 )12 )
10 13
11 // productionBrokerLayout is the single production broker filesystem contract.14 // productionBrokerLayout is the single production broker filesystem contract.
@@ -15,6 +18,27 @@ func productionBrokerConfigPath() string {
15 return filepath.Join(filepath.Dir(platform.ConfigPath()), "broker.json")18 return filepath.Join(filepath.Dir(platform.ConfigPath()), "broker.json")
16 }19 }
17 20
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+
18 func productionBrokerLayout() brokerconfig.Layout {42 func productionBrokerLayout() brokerconfig.Layout {
19 root := filepath.Join(platform.StateDir(), "broker")43 root := filepath.Join(platform.StateDir(), "broker")
20 runtime := filepath.Join(ipc.SocketDir(), "broker")44 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 (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 )8 )
9 9
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+
10 func TestProductionBrokerLayoutUsesDedicatedRuntimeStateAndConfig(t *testing.T) {26 func TestProductionBrokerLayoutUsesDedicatedRuntimeStateAndConfig(t *testing.T) {
11 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())27 t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
12 t.Setenv("XDG_STATE_HOME", t.TempDir())28 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 (
14 "github.com/bnema/vev/internal/protocol/catalogue"14 "github.com/bnema/vev/internal/protocol/catalogue"
15 )15 )
16 16
17+// Remote daemon observation composition (Plan 001 P7.4b).
18+//
17 // brokerRemoteProbe observes one configured daemon over the broker-owned19 // brokerRemoteProbe observes one configured daemon over the broker-owned
18 // multiplexed carriage. It opens an observation stream, never an attachment,20 // multiplexed carriage. It opens an observation stream, never an attachment,
19 // and asks the daemon for its local catalogue through the existing typed21 // and asks the daemon for its local catalogue through the existing typed
20 // command protocol. Authenticated daemon identity and process incarnation come22 // command protocol. Authenticated daemon identity and process incarnation come
21 // from the physical handshake rather than from the configured hostname.23 // 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.
22 type brokerRemoteProbe struct {31 type brokerRemoteProbe struct {
23 epoch ports.BrokerEpoch32 epoch ports.BrokerEpoch
24- resolver ports.BrokerEndpointResolver33+ routes ports.BrokerRouteAuthority
34+ binder ports.BrokerIdentityBinder
25 connector ports.BrokerEndpointConnector35 connector ports.BrokerEndpointConnector
26- policy func(string) (ports.BrokerPolicy, bool)36+ hosts ports.BrokerHostAuthorityReader
27 }37 }
28 38
29 var _ ports.BrokerHostProbe = (*brokerRemoteProbe)(nil)39 var _ ports.BrokerHostProbe = (*brokerRemoteProbe)(nil)
30 40
31 func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) {41 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 {
33 return ports.BrokerDaemonObservation{}, errors.New("vev: incomplete broker remote probe")43 return ports.BrokerDaemonObservation{}, errors.New("vev: incomplete broker remote probe")
34 }44 }
35- request, err := p.request(registration)45+ request, err := p.request(ctx, registration)
36 if err != nil {46 if err != nil {
37 return ports.BrokerDaemonObservation{}, err47 return ports.BrokerDaemonObservation{}, err
38 }48 }
39- endpoint, err := p.resolver.Resolve(ctx, request)49+ endpoint, err := p.routes.ResolveDialTarget(ctx, request)
40 if err != nil {50 if err != nil {
41 return ports.BrokerDaemonObservation{}, err51 return ports.BrokerDaemonObservation{}, err
42 }52 }
@@ -45,35 +55,59 @@ func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.Remot
45 return ports.BrokerDaemonObservation{}, err55 return ports.BrokerDaemonObservation{}, err
46 }56 }
47 defer physical.Close()57 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 {
49 return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe authenticated binding mismatch")59 return ports.BrokerDaemonObservation{}, errors.New("vev: remote probe authenticated binding mismatch")
50 }60 }
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+ }
51 logical, err := physical.OpenStream(ctx, request)85 logical, err := physical.OpenStream(ctx, request)
52 if err != nil {86 if err != nil {
53 return ports.BrokerDaemonObservation{}, err87 return ports.BrokerDaemonObservation{}, err
54 }88 }
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() }()
56 commandID, err := randomNonzeroUint64()93 commandID, err := randomNonzeroUint64()
57 if err != nil {94 if err != nil {
58 return ports.BrokerDaemonObservation{}, err95 return ports.BrokerDaemonObservation{}, err
59 }96 }
60- if err := logical.SendClient(protocol.CommandRequest{RequestID: commandID, Version: protocol.Version, Slug: "remote-catalog", JSON: true}); err != nil {97+ reply, err := exchangeObservationCommand(ctx, logical, protocol.CommandRequest{RequestID: commandID, Version: protocol.Version, Slug: "remote-catalog", JSON: true})
61- return ports.BrokerDaemonObservation{}, err
62- }
63- reply, err := logical.ReceiveServer()
64 if err != nil {98 if err != nil {
65 return ports.BrokerDaemonObservation{}, err99 return ports.BrokerDaemonObservation{}, err
66 }100 }
67 result, ok := reply.(protocol.CommandResult)101 result, ok := reply.(protocol.CommandResult)
68 if !ok || result.RequestID != commandID || !result.Valid() {102 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")
70 }104 }
71 if result.Outcome != protocol.CommandSucceeded {105 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)
73 }107 }
74 var catalog catalogue.RemoteCatalog108 var catalog catalogue.RemoteCatalog
75 if err := json.Unmarshal([]byte(result.Output), &catalog); err != nil {109 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)
77 }111 }
78 if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {112 if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
79 return ports.BrokerDaemonObservation{}, err113 return ports.BrokerDaemonObservation{}, err
@@ -82,21 +116,62 @@ func (p *brokerRemoteProbe) Probe(ctx context.Context, registration domain.Remot
82 Identity: physical.Identity(),116 Identity: physical.Identity(),
83 Incarnation: physical.Incarnation(),117 Incarnation: physical.Incarnation(),
84 ProtocolVersion: catalog.ProtocolVersion,118 ProtocolVersion: catalog.ProtocolVersion,
85- Capabilities: 0,
86 Availability: domain.RemoteAvailabilityReachable,119 Availability: domain.RemoteAvailabilityReachable,
87 InventoryKnown: true,120 InventoryKnown: true,
88 Sessions: catalog.Sessions,121 Sessions: catalog.Sessions,
89 }, nil122 }, nil
90 }123 }
91 124
92-func (p *brokerRemoteProbe) request(registration domain.RemoteRegistration) (ports.BrokerOpenStreamRequest, error) {125+// exchangeObservationCommand sends one typed command and waits for one reply
93- if p.policy == nil {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 {
94 return ports.BrokerOpenStreamRequest{}, errors.New("vev: remote probe has no policy authority")165 return ports.BrokerOpenStreamRequest{}, errors.New("vev: remote probe has no policy authority")
95 }166 }
96- policy, ok := p.policy(registration.Endpoint)167+ record, ok, err := p.hosts.LookupHost(ctx, registration.Endpoint)
97- if !ok {168+ if err != nil {
98- return ports.BrokerOpenStreamRequest{}, errors.New("vev: remote probe endpoint is not configured")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"}
99 }173 }
174+ policy := record.Policy
100 connection, err := randomConnectionID()175 connection, err := randomConnectionID()
101 if err != nil {176 if err != nil {
102 return ports.BrokerOpenStreamRequest{}, err177 return ports.BrokerOpenStreamRequest{}, err
@@ -109,6 +184,9 @@ func (p *brokerRemoteProbe) request(registration domain.RemoteRegistration) (por
109 Epoch: p.epoch, Purpose: ports.BrokerStreamObservation,184 Epoch: p.epoch, Purpose: ports.BrokerStreamObservation,
110 Connection: connection, Stream: ports.BrokerStreamID(stream),185 Connection: connection, Stream: ports.BrokerStreamID(stream),
111 Endpoint: registration.Endpoint, Registration: registration, Policy: policy,186 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,
112 }, nil190 }, nil
113 }191 }
114 192
M internal/app/broker_remote_probe_test.go +9 -5
@@ -15,12 +15,10 @@ func TestBrokerRemoteProbeRequestIsObservationOnly(t *testing.T) {
15 policy := ports.BrokerPolicy{ProtocolVersion: protocol.Version, CatalogSchemaVersion: 3, Transport: "stdio", Trust: "ssh", Launch: "explicit", Isolation: "user", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}15 policy := ports.BrokerPolicy{ProtocolVersion: protocol.Version, CatalogSchemaVersion: 3, Transport: "stdio", Trust: "ssh", Launch: "explicit", Isolation: "user", EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned}
16 probe := &brokerRemoteProbe{16 probe := &brokerRemoteProbe{
17 epoch: 7,17 epoch: 7,
18- policy: func(endpoint string) (ports.BrokerPolicy, bool) {18+ hosts: &routeTestHosts{record: ports.BrokerHostRecord{Registration: registration, Policy: policy}, found: true},
19- return policy, endpoint == registration.Endpoint
20- },
21 }19 }
22 20
23- request, err := probe.request(registration)21+ request, err := probe.request(context.Background(), registration)
24 require.NoError(t, err)22 require.NoError(t, err)
25 require.NoError(t, request.Validate())23 require.NoError(t, request.Validate())
26 require.Equal(t, ports.BrokerStreamObservation, request.Purpose)24 require.Equal(t, ports.BrokerStreamObservation, request.Purpose)
@@ -28,10 +26,16 @@ func TestBrokerRemoteProbeRequestIsObservationOnly(t *testing.T) {
28 require.Empty(t, request.Env)26 require.Empty(t, request.Env)
29 require.Equal(t, registration, request.Registration)27 require.Equal(t, registration, request.Registration)
30 require.Equal(t, policy, request.Policy)28 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())
31 }35 }
32 36
33 func TestBrokerRemoteProbeRejectsUnconfiguredEndpointBeforeDial(t *testing.T) {37 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{}}
35 _, err := probe.Probe(context.Background(), domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1})39 _, err := probe.Probe(context.Background(), domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1})
36 require.ErrorContains(t, err, "incomplete broker remote probe")40 require.ErrorContains(t, err, "incomplete broker remote probe")
37 }41 }
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 (
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/wire"18 "github.com/bnema/vev/internal/protocol/wire"
19+ clientusecase "github.com/bnema/vev/internal/usecase/client"
19 commandusecase "github.com/bnema/vev/internal/usecase/command"20 commandusecase "github.com/bnema/vev/internal/usecase/command"
20 "github.com/bnema/vev/internal/usecase/daemon"21 "github.com/bnema/vev/internal/usecase/daemon"
21 )22 )
@@ -152,18 +153,20 @@ func (e *commandOutcomeUnknown) Unwrap() error { return e.detail }
152 func (*commandOutcomeUnknown) Is(target error) bool { return target == errCommandOutcomeUnknown }153 func (*commandOutcomeUnknown) Is(target error) bool { return target == errCommandOutcomeUnknown }
153 154
154 type cmdDeps struct {155 type cmdDeps struct {
155- stdout io.Writer156+ stdout io.Writer
156- getenv func(string) string157+ getenv func(string) string
157- dial func(context.Context, string) (wire.Transport, error)158+ connect func(context.Context) (ports.BrokerService, error)
158- ensure func(context.Context, string) (wire.Transport, error)159+ dial func(context.Context, string) (wire.Transport, error)
159- clock ports.Clock160+ ensure func(context.Context, string) (wire.Transport, error)
161+ clock ports.Clock
160 }162 }
161 163
162 func runCmd(ctx context.Context, invocation cmdInvocation) error {164 func runCmd(ctx context.Context, invocation cmdInvocation) error {
163 return runCmdWithDeps(ctx, invocation, cmdDeps{165 return runCmdWithDeps(ctx, invocation, cmdDeps{
164- stdout: os.Stdout,166+ stdout: os.Stdout,
165- getenv: os.Getenv,167+ getenv: os.Getenv,
166- dial: realDial,168+ connect: connectProductionBroker,
169+ dial: realDial,
167 ensure: func(ctx context.Context, dir string) (wire.Transport, error) {170 ensure: func(ctx context.Context, dir string) (wire.Transport, error) {
168 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)171 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
169 },172 },
@@ -200,11 +203,28 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
200 return usagef("too many command arguments")203 return usagef("too many command arguments")
201 }204 }
202 205
203- dial := deps.dial206+ if deps.connect != nil {
204- if invocation.slug == "remote-catalog" && deps.ensure != nil {207+ service, err := deps.connect(ctx)
205- dial = deps.ensure208+ 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)
206 }225 }
207- transport, err := dial(ctx, ipc.SocketDir())226+
227+ transport, err := deps.dial(ctx, ipc.SocketDir())
208 if err != nil {228 if err != nil {
209 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}229 return &exitCoded{code: 3, err: fmt.Errorf("%w: %w", errDaemonUnreachable, err)}
210 }230 }
@@ -284,3 +304,32 @@ func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps)
284 }304 }
285 return nil305 return nil
286 }306 }
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 (
12 "time"12 "time"
13 13
14 "github.com/bnema/vev/internal/adapters/sessionwire"14 "github.com/bnema/vev/internal/adapters/sessionwire"
15+ "github.com/bnema/vev/internal/ports"
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/protocol"17 "github.com/bnema/vev/internal/protocol"
17 "github.com/bnema/vev/internal/protocol/wire"18 "github.com/bnema/vev/internal/protocol/wire"
@@ -207,23 +208,21 @@ func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
207 }208 }
208 }209 }
209 210
210-func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) {211+func TestRemoteCatalogCommandUsesBroker(t *testing.T) {
211- transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}212+ stream := newSeamBrokerStream(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded})
212- ensureCalls := 0213+ service := newSeamBrokerService(stream)
213 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{214 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
214- stdout: io.Discard,215+ stdout: io.Discard,
215- getenv: func(string) string { return "" },216+ getenv: func(string) string { return "" },
217+ connect: func(context.Context) (ports.BrokerService, error) { return service, nil },
216 dial: func(context.Context, string) (wire.Transport, error) {218 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")
218 return nil, errors.New("unexpected dial")220 return nil, errors.New("unexpected dial")
219 },221 },
220- ensure: func(context.Context, string) (wire.Transport, error) {
221- ensureCalls++
222- return transport, nil
223- },
224 })222 })
225 require.NoError(t, err)223 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)
227 }226 }
228 227
229 func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {228 func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
M internal/app/connectivity_matrix.go +61 -61
@@ -61,8 +61,14 @@ type connectivityEntry struct {
61 Summary string61 Summary string
62 Owner connectivityOwner62 Owner connectivityOwner
63 // DirectDialDebt lists today's direct-daemon dial symbols that the63 // 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.
65 DirectDialDebt []string66 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
66 // HostStoreWrite reports a RemoteHostStore constructor/writer today.72 // HostStoreWrite reports a RemoteHostStore constructor/writer today.
67 // Only the broker owns the host store after cutover.73 // Only the broker owns the host store after cutover.
68 HostStoreWrite bool74 HostStoreWrite bool
@@ -77,28 +83,19 @@ type connectivityEntry struct {
77 var connectivityMatrix = []connectivityEntry{83 var connectivityMatrix = []connectivityEntry{
78 {84 {
79 Kind: "kindAttach",85 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",
81 Owner: connectivityBrokerOnly,87 Owner: connectivityBrokerOnly,
82 DirectDialDebt: []string{88 DirectDialDebt: []string{
83- "localDaemonDialer.Dial (run.go)",
84- "dialOnlyLocalDialer.Dial inventory control (run.go)",
85 "ensureDaemonWithLifecycle detached creation (run.go createDetachedLocalSession)",89 "ensureDaemonWithLifecycle detached creation (run.go createDetachedLocalSession)",
86- "registry.ResolveEndpoint remote carriage (run.go runAttachWithDeps)",
87- "listSessionsWithDialer attach preflight (attach_preflight.go sessionExists)",
88 },90 },
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.",
90 },92 },
91 {93 {
92- Kind: "kindList",94+ Kind: "kindList",
93- Summary: "session list: local, per-host, and --all",95+ Summary: "session list: local, per-host, and --all",
94- Owner: connectivityBrokerOnly,96+ Owner: connectivityBrokerOnly,
95- DirectDialDebt: []string{97+ Migrated: true,
96- "realDial listSessionsWithDialer (run.go)",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.",
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.",
102 },99 },
103 {100 {
104 Kind: "kindHost",101 Kind: "kindHost",
@@ -109,15 +106,11 @@ var connectivityMatrix = []connectivityEntry{
109 Notes: "Second host-store writer today; broker is the sole writer after cutover. Host list is store-only (no dial).",106 Notes: "Second host-store writer today; broker is the sole writer after cutover. Host list is store-only (no dial).",
110 },107 },
111 {108 {
112- Kind: "kindKill",109+ Kind: "kindKill",
113- Summary: "kill one session, kill-all, explicit daemon-stop",110+ Summary: "kill one session, kill-all, explicit daemon-stop",
114- Owner: connectivityBrokerOnly,111+ Owner: connectivityBrokerOnly,
115- DirectDialDebt: []string{112+ Migrated: true,
116- "waitForDaemonOrLifecycle realDial kill path (run.go)",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.",
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.",
121 },114 },
122 {115 {
123 Kind: "kindCmd",116 Kind: "kindCmd",
@@ -130,28 +123,32 @@ var connectivityMatrix = []connectivityEntry{
130 Notes: "Command tracker semantics unchanged; only the carriage moves behind the broker.",123 Notes: "Command tracker semantics unchanged; only the carriage moves behind the broker.",
131 },124 },
132 {125 {
133- Kind: "kindUIDriver",126+ Kind: "kindUIDriver",
134- Summary: "headless UI-driver attach",127+ Summary: "headless UI-driver attach",
135- Owner: connectivityBrokerOnly,128+ Owner: connectivityBrokerOnly,
136- DirectDialDebt: []string{129+ Migrated: true,
137- "localDaemonDialer launch/local paths (ui_driver.go, run.go runAttachWithDeps)",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.",
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.",
141 },131 },
142 {132 {
143- Kind: "kindWebDaemon",133+ Kind: "kindUIRemoteCleanup",
144- Summary: "browser gateway launcher",134+ Summary: "UI fixture cleanup through broker control",
145- Owner: connectivityBrokerOnly,135+ Owner: connectivityBrokerOnly,
146- DirectDialDebt: []string{"gateway session path via runAttachWithDeps (web_daemon.go)"},136+ Migrated: true,
147- Notes: "Launcher mechanics stay local; gateway session traffic goes through the broker like any client.",137+ Notes: "Cleanup uses the broker-owned daemon-stop operation and keeps no direct daemon carriage.",
148 },138 },
149 {139 {
150- Kind: "kindWebServe",140+ Kind: "kindWebDaemon",
151- Summary: "browser gateway server",141+ Summary: "browser gateway launcher",
152- Owner: connectivityBrokerOnly,142+ Owner: connectivityBrokerOnly,
153- DirectDialDebt: []string{"gateway session path via runAttachWithDeps (web_daemon.go)"},143+ Migrated: true,
154- Notes: "One client runner per WebSocket; no bypass of the broker façade.",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.",
155 },152 },
156 {153 {
157 Kind: "kindDaemon",154 Kind: "kindDaemon",
@@ -183,27 +180,18 @@ var connectivityMatrix = []connectivityEntry{
183 Owner: connectivityTransportInfra,180 Owner: connectivityTransportInfra,
184 Notes: "Blind proxy may forward raw envelopes; exposes no bytes to use cases and owns no sessions.",181 Notes: "Blind proxy may forward raw envelopes; exposes no bytes to use cases and owns no sessions.",
185 },182 },
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- },
201 {183 {
202 Kind: "kindWebRenew",184 Kind: "kindWebRenew",
203 Summary: "browser token renewal",185 Summary: "browser token renewal",
204 Owner: connectivityLocalOnly,186 Owner: connectivityLocalOnly,
205 Notes: "Local control only; no daemon or session connectivity.",187 Notes: "Local control only; no daemon or session connectivity.",
206 },188 },
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+ },
207 {195 {
208 Kind: "kindHelp",196 Kind: "kindHelp",
209 Summary: "usage text",197 Summary: "usage text",
@@ -222,6 +210,12 @@ var connectivityMatrix = []connectivityEntry{
222 Owner: connectivityLocalOnly,210 Owner: connectivityLocalOnly,
223 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.",211 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.",
224 },212 },
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+ },
225 {219 {
226 Kind: "kindBrokerClient",220 Kind: "kindBrokerClient",
227 Summary: "hidden _broker-client autonomous offline client harness",221 Summary: "hidden _broker-client autonomous offline client harness",
@@ -234,6 +228,12 @@ var connectivityMatrix = []connectivityEntry{
234 Owner: connectivityLocalOnly,228 Owner: connectivityLocalOnly,
235 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.",229 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.",
236 },230 },
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+ },
237 {237 {
238 Kind: "kindBrokerStatus",238 Kind: "kindBrokerStatus",
239 Summary: "hidden _broker-status offline broker probe and connect-or-spawn",239 Summary: "hidden _broker-status offline broker probe and connect-or-spawn",
@@ -247,7 +247,7 @@ var connectivityMatrix = []connectivityEntry{
247 DirectDialDebt: []string{247 DirectDialDebt: []string{
248 "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)",248 "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)",
249 },249 },
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.",
251 },251 },
252 {252 {
253 Kind: "kindBrokerMuxQUICBootstrap",253 Kind: "kindBrokerMuxQUICBootstrap",
@@ -262,7 +262,7 @@ var connectivityMatrix = []connectivityEntry{
262 DirectDialDebt: []string{262 DirectDialDebt: []string{
263 "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)",263 "ipc.DialMuxContext local daemonmux carriage (broker_offline_mux.go)",
264 },264 },
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.",
266 },266 },
267 }267 }
268 268
M internal/app/connectivity_matrix_test.go +97 -16
@@ -84,6 +84,10 @@ func TestConnectivityMatrixOwnershipRules(t *testing.T) {
84 switch entry.Owner {84 switch entry.Owner {
85 case connectivityBrokerOnly:85 case connectivityBrokerOnly:
86 hasDebt := len(entry.DirectDialDebt) > 0 || entry.HostStoreWrite || entry.PersistMutation86 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+ }
87 require.True(t, hasDebt, "broker-only %q must declare migration debt (direct dial, host-store write, or persist mutation)", entry.Kind)91 require.True(t, hasDebt, "broker-only %q must declare migration debt (direct dial, host-store write, or persist mutation)", entry.Kind)
88 case connectivityTransportInfra:92 case connectivityTransportInfra:
89 require.False(t, entry.HostStoreWrite, "infra %q must never own the host store", entry.Kind)93 require.False(t, entry.HostStoreWrite, "infra %q must never own the host store", entry.Kind)
@@ -144,23 +148,48 @@ func TestConnectivityMatrixCurrentDebtPresent(t *testing.T) {
144 require.Contains(t, body, symbol, "expected debt symbol %q in %q", symbol, file)148 require.Contains(t, body, symbol, "expected debt symbol %q in %q", symbol, file)
145 }149 }
146 150
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")
151 assertContains("func ensureDaemonWithLifecycle", "spawn.go")151 assertContains("func ensureDaemonWithLifecycle", "spawn.go")
152- assertContains("func waitForDaemonOrLifecycle", "spawn.go")
153 assertContains("func realDial", "spawn.go")152 assertContains("func realDial", "spawn.go")
154 assertContains("func realSpawn", "spawn.go")153 assertContains("func realSpawn", "spawn.go")
155- assertContains("func (d remoteHostDeps) hostStore", "remote_hosts.go")154+}
156- assertContains("func hostAdd", "remote_hosts.go")155+
157- assertContains("func hostRm", "remote_hosts.go")156+// TestConnectivityMatrixListKillStopAreBrokerOnly pins the P7.4f cutover for
158- assertContains("func listSessionsWithDialer", "run.go")157+// the list/kill/stop rows: each reaches the per-user broker through the private
159- assertContains("func sessionExists", "attach_preflight.go")158+// connectBroker seam and keeps no removed bypass, so runList and runKill/stop
160- assertContains("func runOfflineNamedKill", "run.go")159+// can never read durable state or signal a process directly.
161- assertContains("persist.LoadReadOnly", "run.go")160+func TestConnectivityMatrixListKillStopAreBrokerOnly(t *testing.T) {
162- assertContains("persist.OpenOrCreate", "run.go")161+ t.Parallel()
163- assertContains("func newClientHostRegistry", "client_hosts.go")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+ }
164 }193 }
165 194
166 // TestConnectivityMatrixSingleHostStoreWriterSet fails when a new195 // TestConnectivityMatrixSingleHostStoreWriterSet fails when a new
@@ -178,8 +207,60 @@ func TestConnectivityMatrixSingleHostStoreWriterSet(t *testing.T) {
178 }207 }
179 }208 }
180 sort.Strings(writers)209 sort.Strings(writers)
181- require.Equal(t, []string{"remote_hosts.go", "run.go"}, writers,210+ require.Empty(t, writers,
182- "host-store constructor sites changed; update the matrix debt set in the same change")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+ }
183 }264 }
184 265
185 // commandKindsFromRunGo extracts the kind* const names from run.go so the266 // 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) {
719 awaitText(t, p, sz, "TEMP")719 awaitText(t, p, sz, "TEMP")
720 require.NoError(t, tr.Close())720 require.NoError(t, tr.Close())
721 721
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))
723 select {725 select {
724 case err := <-served:726 case err := <-served:
725 require.NoError(t, err)727 require.NoError(t, err)
@@ -731,7 +733,7 @@ func TestIntegration_EphemeralNotListedAfterDaemonRestart(t *testing.T) {
731 sessions := listRemoteSessions(t, dir)733 sessions := listRemoteSessions(t, dir)
732 require.Empty(t, sessions.Sessions)734 require.Empty(t, sessions.Sessions)
733 735
734- require.NoError(t, runKill(context.Background(), "", false, true))736+ require.NoError(t, killDaemon(dir))
735 select {737 select {
736 case err := <-served2:738 case err := <-served2:
737 require.NoError(t, err)739 require.NoError(t, err)
@@ -1007,61 +1009,6 @@ func TestMultipleClientsOneLifecycleOwner(t *testing.T) {
1007 require.Zero(t, spawns.Load())1009 require.Zero(t, spawns.Load())
1008 }1010 }
1009 1011
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-
1065 func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {1012 func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
1066 runtimeDir := filepath.Join(t.TempDir(), "runtime")1013 runtimeDir := filepath.Join(t.TempDir(), "runtime")
1067 stateDir := filepath.Join(t.TempDir(), "state")1014 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
2 2
3 import (3 import (
4 "context"4 "context"
5- "errors"
6 "fmt"5 "fmt"
7 "io"6 "io"
8- "log/slog"
9 "os"7 "os"
10 "sort"8 "sort"
11 "text/tabwriter"9 "text/tabwriter"
12 10
13- remoteadapter "github.com/bnema/vev/internal/adapters/remote"
14 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
15- "github.com/bnema/vev/internal/platform"
16 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/internal/protocol"13 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/catalogue"14 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -24,36 +20,18 @@ const (
24 hostActionList = "list"20 hostActionList = "list"
25 )21 )
26 22
27-// remoteHostDeps holds injectable seams for host management and remote listing.
28 type remoteHostDeps struct {23 type remoteHostDeps struct {
29- stateDir func() string24+ connect func(context.Context) (ports.BrokerService, error)
30- store ports.RemoteHostStore25+ stdout io.Writer
31- catalog ports.RemoteCatalogClient
32- localList func(context.Context) ([]protocol.SessionInfo, error)
33- stdout io.Writer
34 }26 }
35 27
36 func defaultRemoteHostDeps() remoteHostDeps {28 func defaultRemoteHostDeps() remoteHostDeps {
37- return remoteHostDeps{29+ return remoteHostDeps{connect: connectProductionBroker, stdout: os.Stdout}
38- stateDir: platform.StateDir,
39- catalog: remoteadapter.NewCatalogClient(),
40- localList: listLocalSessions,
41- stdout: os.Stdout,
42- }
43 }30 }
44 31
45 func (d remoteHostDeps) withDefaults() remoteHostDeps {32 func (d remoteHostDeps) withDefaults() remoteHostDeps {
46- if d.stateDir == nil {33+ if d.connect == nil {
47- d.stateDir = platform.StateDir34+ d.connect = connectProductionBroker
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
57 }35 }
58 if d.stdout == nil {36 if d.stdout == nil {
59 d.stdout = os.Stdout37 d.stdout = os.Stdout
@@ -61,200 +39,114 @@ func (d remoteHostDeps) withDefaults() remoteHostDeps {
61 return d39 return d
62 }40 }
63 41
64-func (d remoteHostDeps) hostStore() ports.RemoteHostStore {
65- return d.store
66-}
67-
68 func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error {42 func runHostCommand(ctx context.Context, cmd command, deps remoteHostDeps) error {
69- _ = ctx
70 deps = deps.withDefaults()43 deps = deps.withDefaults()
71-44+ service, err := deps.connect(ctx)
45+ if err != nil {
46+ return err
47+ }
48+ defer func() { _ = service.Close() }()
72 switch cmd.hostAction {49 switch cmd.hostAction {
73 case hostActionAdd:50 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
75 case hostActionRm:60 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
77 case hostActionList:82 case hostActionList:
78- return hostList(deps)83+ return printBrokerHosts(deps.stdout, service.Snapshot())
79 default:84 default:
80 return usagef("unknown host action %q", cmd.hostAction)85 return usagef("unknown host action %q", cmd.hostAction)
81 }86 }
82 }87 }
83 88
84-func hostAdd(deps remoteHostDeps, target string) error {89+func printBrokerHosts(w io.Writer, snapshot ports.BrokerSnapshot) error {
85- if err := domain.ValidateRemoteHostTarget(target); err != nil {90+ if err := snapshot.Validate(); err != nil {
86- return err91+ return fmt.Errorf("vev: reading broker catalogue: %w", 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
97 }92 }
98- deleted, err := deps.hostStore().Remove(target)93+ var endpoints []string
99- if err != nil {94+ for _, daemon := range snapshot.Daemons {
100- return fmt.Errorf("vev: removing host %q: %w", target, err)95+ if !daemon.Local {
101- }96+ endpoints = append(endpoints, daemon.Endpoint)
102- if !deleted {97+ }
103- return fmt.Errorf("vev: unknown host %q", target)
104 }98 }
105- return nil99+ sort.Strings(endpoints)
106-}100+ if len(endpoints) == 0 {
107-101+ _, err := fmt.Fprintln(w, "no hosts")
108-func hostList(deps remoteHostDeps) error {
109- hosts, err := mergeKnownHosts(deps)
110- if err != nil {
111 return err102 return err
112 }103 }
113- if len(hosts) == 0 {104+ tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
114- _, _ = fmt.Fprintln(deps.stdout, "no hosts")
115- return nil
116- }
117- tw := tabwriter.NewWriter(deps.stdout, 0, 4, 2, ' ', 0)
118 _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE")105 _, _ = fmt.Fprintln(tw, "TARGET\tSOURCE")
119- for _, host := range hosts {106+ for _, endpoint := range endpoints {
120- _, _ = fmt.Fprintf(tw, "%s\t%s\n", host.Target, remoteHostSource(host))107+ _, _ = fmt.Fprintf(tw, "%s\tbroker\n", endpoint)
121 }108 }
122 return tw.Flush()109 return tw.Flush()
123 }110 }
124 111
125-func remoteHostSource(host domain.RemoteHost) string {112+func runBrokerSnapshotList(cmd command, snapshot ports.BrokerSnapshot, stdout io.Writer) error {
126- switch {113+ if err := snapshot.Validate(); err != nil {
127- case host.Pinned && host.Learned:114+ return fmt.Errorf("vev: reading broker catalogue: %w", err)
128- return "pinned,learned"
129- case host.Pinned:
130- return "pinned"
131- case host.Learned:
132- return "learned"
133- default:
134- return ""
135 }115 }
136-}116+ var sessions []protocol.SessionInfo
137-117+ found := cmd.listHost == ""
138-func runRemoteList(ctx context.Context, cmd command, deps remoteHostDeps) error {118+ for _, daemon := range snapshot.Daemons {
139- deps = deps.withDefaults()119+ if cmd.listHost != "" && (daemon.Local || daemon.Endpoint != cmd.listHost) {
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))
189 continue120 continue
190 }121 }
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+ }
192 }129 }
193- printSessions(deps.stdout, all)130+ if !found {
194- if len(hostErrs) > 0 {131+ return fmt.Errorf("vev: unknown host %q", cmd.listHost)
195- return errors.Join(hostErrs...)
196 }132 }
133+ printSessions(stdout, sessions)
197 return nil134 return nil
198 }135 }
199 136
200 func catalogSessionsAsInfo(host string, sessions []catalogue.RemoteCatalogSession) []protocol.SessionInfo {137 func catalogSessionsAsInfo(host string, sessions []catalogue.RemoteCatalogSession) []protocol.SessionInfo {
201 out := make([]protocol.SessionInfo, 0, len(sessions))138 out := make([]protocol.SessionInfo, 0, len(sessions))
202 for _, session := range sessions {139 for _, session := range sessions {
203- info := protocol.SessionInfo{140+ info := protocol.SessionInfo{Name: domain.RemoteSessionDisplay(session.Name, host), Ephemeral: session.Ephemeral, Tabs: catalogue.SaturateUint16(catalogue.CatalogTabCount(session)), Attached: session.Attached}
204- Name: domain.RemoteSessionDisplay(session.Name, host),
205- Ephemeral: session.Ephemeral,
206- Tabs: catalogue.SaturateUint16(catalogue.CatalogTabCount(session)),
207- Attached: session.Attached,
208- }
209 switch session.State {141 switch session.State {
210 case catalogue.RemoteCatalogSessionUp:142 case catalogue.RemoteCatalogSessionUp:
211 info.State = protocol.SessionUp143 info.State = protocol.SessionUp
212 case catalogue.RemoteCatalogSessionDown:144 case catalogue.RemoteCatalogSessionDown:
213 info.State = protocol.SessionDown145 info.State = protocol.SessionDown
214- case catalogue.RemoteCatalogSessionBroken:
215- info.State = protocol.SessionBroken
216 default:146 default:
217- slog.Debug("remote catalog session has unknown state", "host", host, "session", session.Name, "state", session.State)
218 info.State = protocol.SessionBroken147 info.State = protocol.SessionBroken
219 }148 }
220 out = append(out, info)149 out = append(out, info)
221 }150 }
222 return out151 return out
223 }152 }
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 {
172 // stream names exactly how the daemon must admit it, while control and172 // stream names exactly how the daemon must admit it, while control and
173 // observation streams carry none (0). name is validated session-name173 // observation streams carry none (0). name is validated session-name
174 // authority for create-named and is empty for every other variant.174 // 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.
175 message OpenStream {179 message OpenStream {
176 BrokerStreamRef ref = 1;180 BrokerStreamRef ref = 1;
177 // Closed taxonomy: 1 = attachment, 2 = control, 3 = observation.181 // Closed taxonomy: 1 = attachment, 2 = control, 3 = observation.
@@ -189,6 +193,9 @@ message OpenStream {
189 uint32 admission = 9;193 uint32 admission = 9;
190 // Validated session name for create-named; empty otherwise.194 // Validated session name for create-named; empty otherwise.
191 string name = 10;195 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;
192 }199 }
193 200
194 // ClientStreamData carries one opaque client-to-daemon stream frame.201 // 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) {
437 func TestPoolLocalStreamsShareOnePhysicalEntry(t *testing.T) {437 func TestPoolLocalStreamsShareOnePhysicalEntry(t *testing.T) {
438 var connects atomic.Int32438 var connects atomic.Int32
439 var physical *fakePhysical439 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) {
441 connects.Add(1)441 connects.Add(1)
442 physical = &fakePhysical{endpoint: endpoint, done: make(chan struct{})}442 physical = &fakePhysical{endpoint: endpoint, done: make(chan struct{})}
443 return physical, nil443 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) {
20 Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1},20 Endpoint: "remote", DisplayOrigin: "remote", LifecycleID: domain.SessionLifecycleID{1},
21 SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"),21 SessionName: "work", Stopped: true, StoppedTab: domain.NewStableTabSelector("tab-1"),
22 }22 }
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}}
24 24
25 require.Equal(t, "Switching to work@remote…", transitionMessage(live))25 require.Equal(t, "Switching to work@remote…", transitionMessage(live))
26 require.Equal(t, "Starting work@remote…", transitionMessage(stopped))26 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 collapsed to keep the page light
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