1package daemon
2
3import (
4 "testing"
5
6 "github.com/stretchr/testify/require"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"
11)
12
13// admittedTransport wraps a typed server connection and stamps the provisioned
14// admission an accepting side would have resolved from its binding. It
15// implements the optional ports.SessionAdmissionProvider seam the daemon asserts
16// structurally, returning a defensive copy exactly as a real accepted
17// connection does. present=false models a legacy connection that carries no
18// provisioned admission at all.
19type admittedTransport struct {
20 ports.ServerConnection
21 admission ports.SessionAdmission
22 present bool
23}
24
25func (t *admittedTransport) SessionAdmission() (ports.SessionAdmission, bool) {
26 if !t.present {
27 return ports.SessionAdmission{}, false
28 }
29 return t.admission.Clone(), true
30}
31
32// admissionTestPolicy builds one valid exact policy whose environment-policy
33// owner matches the supplied carriage locality, as a provisioned binding does.
34func admissionTestPolicy(origin ports.SessionConnectionOrigin) ports.BrokerPolicy {
35 environment := protocol.EnvironmentPolicyClientOwned
36 transport := "unix-mux"
37 if origin == ports.SessionOriginRemote {
38 environment = protocol.EnvironmentPolicyDaemonOwned
39 transport = "ssh-quic"
40 }
41 return ports.BrokerPolicy{
42 ProtocolVersion: protocol.Version, CatalogSchemaVersion: 3,
43 EnvironmentPolicy: environment, Transport: transport,
44 Trust: "same-user", Launch: "explicit", Isolation: "per-user",
45 }
46}
47
48func admissionTestExact(name string, marker byte) protocol.ExactSessionTarget {
49 return protocol.ExactSessionTarget{LifecycleID: domain.SessionLifecycleID{marker}, SessionName: name}
50}
51
52func ptrExactTarget(target protocol.ExactSessionTarget) *protocol.ExactSessionTarget {
53 return &target
54}
55
56// TestValidateSessionHelloAdmissionMatrix pins the closed admission contract:
57// every admission variant maps to exactly one intent and credential shape, and a
58// lying locality, policy, environment, name, target, purpose, or admission
59// variant is refused rather than narrowed.
60func TestValidateSessionHelloAdmissionMatrix(t *testing.T) {
61 localPolicy := admissionTestPolicy(ports.SessionOriginLocal)
62 remotePolicy := admissionTestPolicy(ports.SessionOriginRemote)
63 target := admissionTestExact("work", 1)
64 localEnv := []string{"TERM=xterm-256color", "PATH=/bin"}
65
66 local := func(admission ports.SessionAdmission) ports.SessionAdmission {
67 admission.Origin = ports.SessionOriginLocal
68 admission.Policy = localPolicy
69 admission.Purpose = ports.BrokerStreamAttachment
70 return admission
71 }
72 remote := func(admission ports.SessionAdmission) ports.SessionAdmission {
73 admission.Origin = ports.SessionOriginRemote
74 admission.Policy = remotePolicy
75 admission.Purpose = ports.BrokerStreamAttachment
76 return admission
77 }
78
79 tests := []struct {
80 name string
81 hello protocol.Hello
82 admission ports.SessionAdmission
83 wantErr bool
84 }{
85 {
86 name: "local ephemeral creation",
87 hello: protocol.Hello{
88 Intent: protocol.IntentEphemeral, Cwd: "/client/cwd", Env: localEnv,
89 },
90 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateEphemeral, Env: localEnv}),
91 },
92 {
93 name: "local named creation",
94 hello: protocol.Hello{
95 Intent: protocol.IntentNew, Name: "work", Cwd: "/client/cwd", Env: localEnv,
96 },
97 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work", Env: localEnv}),
98 },
99 {
100 name: "local named attach",
101 hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", Cwd: "/client/cwd", Env: localEnv},
102 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work", Env: localEnv}),
103 },
104 {
105 name: "remote named attach",
106 hello: protocol.Hello{
107 Intent: protocol.IntentAttach, Name: "work", Remote: true,
108 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
109 },
110 admission: remote(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
111 },
112 {
113 name: "named attach cannot create",
114 hello: protocol.Hello{Intent: protocol.IntentNew, Name: "work"},
115 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
116 wantErr: true,
117 },
118 {
119 name: "liar name on a named attach",
120 hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "other"},
121 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
122 wantErr: true,
123 },
124 {
125 name: "named attach smuggles an exact target",
126 hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target},
127 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
128 wantErr: true,
129 },
130 {
131 name: "named attach smuggles a resume token",
132 hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ResumeToken: 4},
133 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionAttachNamed, Name: "work"}),
134 wantErr: true,
135 },
136 {
137 name: "local fresh exact attach",
138 hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target},
139 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionExact, Target: target}),
140 },
141 {
142 name: "local resumed exact attach",
143 hello: protocol.Hello{
144 Intent: protocol.IntentResume, Name: "work", ResumeToken: 7, ExactTarget: &target,
145 },
146 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionExact, Target: target}),
147 },
148 {
149 name: "remote named creation",
150 hello: protocol.Hello{
151 Intent: protocol.IntentNew, Name: "work", Remote: true,
152 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
153 },
154 admission: remote(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work"}),
155 },
156 {
157 name: "remote ephemeral creation",
158 hello: protocol.Hello{
159 Intent: protocol.IntentEphemeral, Remote: true,
160 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
161 },
162 admission: remote(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateEphemeral}),
163 },
164 {
165 name: "remote resume exact",
166 hello: protocol.Hello{
167 Intent: protocol.IntentResume, Name: "work", ResumeToken: 7, ExactTarget: &target,
168 Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
169 },
170 admission: remote(ports.SessionAdmission{Admission: ports.BrokerAdmissionExact, Target: target}),
171 },
172
173 {
174 name: "control purpose is refused",
175 hello: protocol.Hello{Intent: protocol.IntentEphemeral, Cwd: "/client/cwd"},
176 admission: ports.SessionAdmission{Origin: ports.SessionOriginLocal, Policy: localPolicy, Purpose: ports.BrokerStreamControl},
177 wantErr: true,
178 },
179 {
180 name: "observation purpose is refused",
181 hello: protocol.Hello{Intent: protocol.IntentEphemeral, Cwd: "/client/cwd"},
182 admission: ports.SessionAdmission{Origin: ports.SessionOriginLocal, Policy: localPolicy, Purpose: ports.BrokerStreamObservation},
183 wantErr: true,
184 },
185 {
186 name: "zero admission is refused",
187 hello: protocol.Hello{Intent: protocol.IntentEphemeral},
188 admission: ports.SessionAdmission{},
189 wantErr: true,
190 },
191 {
192 name: "unknown admission variant is refused",
193 hello: protocol.Hello{Intent: protocol.IntentEphemeral},
194 admission: local(ports.SessionAdmission{Admission: ports.BrokerStreamAdmission(9)}),
195 wantErr: true,
196 },
197 {
198 name: "liar locality claims remote for a local admission",
199 hello: protocol.Hello{
200 Intent: protocol.IntentEphemeral, Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
201 },
202 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateEphemeral}),
203 wantErr: true,
204 },
205 {
206 name: "liar locality claims local for a remote admission",
207 hello: protocol.Hello{
208 Intent: protocol.IntentEphemeral, Remote: false, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
209 },
210 admission: remote(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateEphemeral}),
211 wantErr: true,
212 },
213 {
214 name: "liar environment policy for a local admission",
215 hello: protocol.Hello{
216 Intent: protocol.IntentEphemeral, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
217 },
218 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateEphemeral, Env: localEnv}),
219 wantErr: true,
220 },
221 {
222 name: "liar environment content",
223 hello: protocol.Hello{
224 Intent: protocol.IntentEphemeral, Env: []string{"TERM=xterm-256color", "PATH=/evil"},
225 },
226 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateEphemeral, Env: localEnv}),
227 wantErr: true,
228 },
229 {
230 name: "liar environment order",
231 hello: protocol.Hello{
232 Intent: protocol.IntentEphemeral, Env: []string{"PATH=/bin", "TERM=xterm-256color"},
233 },
234 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateEphemeral, Env: localEnv}),
235 wantErr: true,
236 },
237 {
238 name: "remote admission rejects a client environment",
239 hello: protocol.Hello{
240 Intent: protocol.IntentNew, Name: "work", Remote: true,
241 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Env: []string{"HOME=/client"},
242 },
243 admission: remote(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work"}),
244 wantErr: true,
245 },
246 {
247 name: "remote admission rejects a client cwd",
248 hello: protocol.Hello{
249 Intent: protocol.IntentNew, Name: "work", Remote: true, Cwd: "/client",
250 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
251 },
252 admission: remote(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work"}),
253 wantErr: true,
254 },
255 {
256 name: "local admission rejects a relative cwd",
257 hello: protocol.Hello{Intent: protocol.IntentNew, Name: "work", Cwd: "relative/path"},
258 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work"}),
259 wantErr: true,
260 },
261 {
262 name: "liar name on a named creation",
263 hello: protocol.Hello{Intent: protocol.IntentNew, Name: "other"},
264 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work"}),
265 wantErr: true,
266 },
267 {
268 name: "named creation smuggles a resume token",
269 hello: protocol.Hello{Intent: protocol.IntentNew, Name: "work", ResumeToken: 9},
270 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work"}),
271 wantErr: true,
272 },
273 {
274 name: "named creation smuggles an exact target",
275 hello: protocol.Hello{Intent: protocol.IntentNew, Name: "work", ExactTarget: &target},
276 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work"}),
277 wantErr: true,
278 },
279 {
280 name: "ephemeral creation smuggles a name",
281 hello: protocol.Hello{Intent: protocol.IntentEphemeral, Name: "work"},
282 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateEphemeral}),
283 wantErr: true,
284 },
285 {
286 name: "ephemeral creation smuggles a resume token",
287 hello: protocol.Hello{Intent: protocol.IntentEphemeral, ResumeToken: 3},
288 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateEphemeral}),
289 wantErr: true,
290 },
291 {
292 name: "wrong intent for a named creation",
293 hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work"},
294 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionCreateNamed, Name: "work"}),
295 wantErr: true,
296 },
297 {
298 name: "liar exact target",
299 hello: protocol.Hello{
300 Intent: protocol.IntentAttach, Name: "work", ExactTarget: ptrExactTarget(admissionTestExact("work", 2)),
301 },
302 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionExact, Target: target}),
303 wantErr: true,
304 },
305 {
306 name: "missing exact target",
307 hello: protocol.Hello{Intent: protocol.IntentAttach, Name: "work"},
308 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionExact, Target: target}),
309 wantErr: true,
310 },
311 {
312 name: "exact admission requires resume intent for a token",
313 hello: protocol.Hello{
314 Intent: protocol.IntentAttach, Name: "work", ResumeToken: 5, ExactTarget: &target,
315 },
316 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionExact, Target: target}),
317 wantErr: true,
318 },
319 {
320 name: "fresh exact admission rejects a resume intent",
321 hello: protocol.Hello{Intent: protocol.IntentResume, Name: "work", ExactTarget: &target},
322 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionExact, Target: target}),
323 wantErr: true,
324 },
325 {
326 name: "session target contradicts the admitted target",
327 hello: protocol.Hello{
328 Intent: protocol.IntentAttach, Name: "work", ExactTarget: &target,
329 SessionTarget: ptrSessionAttachTarget(protocol.SessionAttachTarget{
330 LifecycleID: domain.SessionLifecycleID{2}, SessionName: "work",
331 TabID: "tab-1", TabIndex: protocol.NoTabIndex,
332 }),
333 },
334 admission: local(ports.SessionAdmission{Admission: ports.BrokerAdmissionExact, Target: target}),
335 wantErr: true,
336 },
337 }
338 for _, tt := range tests {
339 t.Run(tt.name, func(t *testing.T) {
340 err := validateSessionHelloAdmission(tt.hello, tt.admission)
341 if tt.wantErr {
342 require.Error(t, err)
343 return
344 }
345 require.NoError(t, err)
346 })
347 }
348}
349
350// TestSessionHelloAdmissionIsDefensiveCopy proves the daemon consumes an
351// independent snapshot of the connection's admitted authority: mutating what the
352// daemon read can never alter what the connection still holds.
353func TestSessionHelloAdmissionIsDefensiveCopy(t *testing.T) {
354 stored := ports.SessionAdmission{
355 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
356 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed,
357 Name: "work", Env: []string{"TERM=xterm-256color"},
358 }
359 tr := &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: stored, present: true}
360
361 first, ok := sessionHelloAdmission(tr)
362 require.True(t, ok)
363 first.Env[0] = "TERM=mutated"
364 first.Name = "mutated"
365 first.Policy.Trust = "mutated"
366
367 second, ok := sessionHelloAdmission(tr)
368 require.True(t, ok)
369 require.Equal(t, []string{"TERM=xterm-256color"}, second.Env)
370 require.Equal(t, "work", second.Name)
371 require.Equal(t, "same-user", second.Policy.Trust)
372
373 legacy, ok := sessionHelloAdmission(&closeTrackingTransport{})
374 require.False(t, ok)
375 require.Equal(t, ports.SessionAdmission{}, legacy, "a legacy connection carries no admission")
376}
377
378// TestRouteRejectsSessionAdmissionContradictionsBeforeMutation proves the
379// admission gate runs at the very start of routing: a contradictory Hello is
380// refused without creating, restoring, or mutating any session, and a control
381// stream can never become an attachment by sending a Hello.
382func TestRouteRejectsSessionAdmissionContradictionsBeforeMutation(t *testing.T) {
383 tests := []struct {
384 name string
385 hello protocol.Hello
386 admission ports.SessionAdmission
387 }{
388 {
389 name: "control purpose cannot attach",
390 hello: protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: defaultSize},
391 admission: ports.SessionAdmission{
392 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
393 Purpose: ports.BrokerStreamControl,
394 },
395 },
396 {
397 name: "liar locality",
398 hello: protocol.Hello{
399 Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: defaultSize,
400 Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
401 },
402 admission: ports.SessionAdmission{
403 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
404 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed, Name: "work",
405 },
406 },
407 {
408 name: "liar name",
409 hello: protocol.Hello{
410 Version: protocol.Version, Intent: protocol.IntentNew, Name: "other", Size: defaultSize,
411 },
412 admission: ports.SessionAdmission{
413 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
414 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed, Name: "work",
415 },
416 },
417 }
418 for _, tt := range tests {
419 t.Run(tt.name, func(t *testing.T) {
420 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
421 tr := &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: tt.admission, present: true}
422 _, _, err := d.route(tt.hello, tr)
423 var protocolErr *protoErr
424 require.ErrorAs(t, err, &protocolErr)
425 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
426 require.Zero(t, sessionCount(d), "a refused admission must never create a session")
427 })
428 }
429}
430
431// TestRouteRemoteEphemeralCreateRequiresAdmissionProof proves an authenticated
432// remote ephemeral creation is admitted with the daemon's own environment and
433// home, while the same Hello without a provisioned admission keeps the legacy
434// refusal: a forged Hello never gains the remote create exception.
435func TestRouteRemoteEphemeralCreateRequiresAdmissionProof(t *testing.T) {
436 remotePoison := []string{"HOME=/daemon/home", "SHELL=/daemon/shell", "PATH=/daemon/bin"}
437 hello := protocol.Hello{
438 Version: protocol.Version, Intent: protocol.IntentEphemeral, Size: defaultSize,
439 Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
440 }
441 admission := ports.SessionAdmission{
442 Origin: ports.SessionOriginRemote, Policy: admissionTestPolicy(ports.SessionOriginRemote),
443 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateEphemeral,
444 }
445
446 proven := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
447 proven.baseEnv = remotePoison
448 proven.dirOrHome = func(cwd string) string {
449 require.Empty(t, cwd, "a remote creation must not resolve a client working directory")
450 return "/daemon/home"
451 }
452 sess, ac, err := proven.route(hello, &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: admission, present: true})
453 require.NoError(t, err)
454 require.NotNil(t, ac)
455 sess.mu.Lock()
456 require.Equal(t, remotePoison, sess.env, "future PTYs must use the daemon environment")
457 require.Equal(t, "/daemon/home", sess.cwd)
458 sess.mu.Unlock()
459 require.NoError(t, proven.killSession(sess, protocol.ReasonSessionKilled, true))
460
461 // The same Hello without a provisioned admission is the legacy shape and
462 // must stay refused rather than inherit the remote create exception.
463 unproven := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
464 legacyTr := &admittedTransport{ServerConnection: &closeTrackingTransport{}, present: false}
465 _, _, err = unproven.route(hello, legacyTr)
466 var protocolErr *protoErr
467 require.ErrorAs(t, err, &protocolErr)
468 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
469 require.Zero(t, sessionCount(unproven), "an unproven remote creation must never create a session")
470}
471
472// TestRouteRemoteNamedCreateUsesDaemonEnvironment proves an authenticated remote
473// named creation uses the daemon's own environment and home rather than any
474// client value.
475func TestRouteRemoteNamedCreateUsesDaemonEnvironment(t *testing.T) {
476 remotePoison := []string{"HOME=/daemon/home", "SHELL=/daemon/shell", "PATH=/daemon/bin"}
477 hello := protocol.Hello{
478 Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: defaultSize,
479 Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
480 }
481 admission := ports.SessionAdmission{
482 Origin: ports.SessionOriginRemote, Policy: admissionTestPolicy(ports.SessionOriginRemote),
483 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed, Name: "work",
484 }
485
486 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
487 d.baseEnv = remotePoison
488 d.dirOrHome = func(cwd string) string {
489 require.Empty(t, cwd, "a remote creation must not resolve a client working directory")
490 return "/daemon/home"
491 }
492 sess, ac, err := d.route(hello, &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: admission, present: true})
493 require.NoError(t, err)
494 require.NotNil(t, ac)
495 sess.mu.Lock()
496 require.Equal(t, remotePoison, sess.env, "future PTYs must use the daemon environment")
497 require.Equal(t, "/daemon/home", sess.cwd)
498 sess.mu.Unlock()
499 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
500}
501
502// TestRouteLocalCreateUsesAdmittedClientEnvironment proves a local admitted
503// creation uses the client's provisioned environment and working directory, and
504// never the daemon's own base environment.
505func TestRouteLocalCreateUsesAdmittedClientEnvironment(t *testing.T) {
506 daemonPoison := []string{"HOME=/daemon/home", "PATH=/daemon/bin"}
507 clientEnv := []string{"HOME=/client/home", "PATH=/client/bin"}
508 cwd := t.TempDir()
509
510 for _, tt := range []struct {
511 name string
512 admission ports.SessionAdmission
513 hello protocol.Hello
514 }{
515 {
516 name: "named",
517 admission: ports.SessionAdmission{
518 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
519 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed,
520 Name: "work", Env: clientEnv,
521 },
522 hello: protocol.Hello{
523 Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: defaultSize,
524 Cwd: cwd, Env: clientEnv, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
525 },
526 },
527 {
528 name: "ephemeral",
529 admission: ports.SessionAdmission{
530 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
531 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateEphemeral,
532 Env: clientEnv,
533 },
534 hello: protocol.Hello{
535 Version: protocol.Version, Intent: protocol.IntentEphemeral, Size: defaultSize,
536 Cwd: cwd, Env: clientEnv, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
537 },
538 },
539 } {
540 t.Run(tt.name, func(t *testing.T) {
541 d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
542 d.baseEnv = daemonPoison
543 tr := &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: tt.admission, present: true}
544 sess, ac, err := d.route(tt.hello, tr)
545 require.NoError(t, err)
546 require.NotNil(t, ac)
547 sess.mu.Lock()
548 require.Equal(t, clientEnv, sess.env, "the local creation must adopt the admitted client environment")
549 require.Equal(t, cwd, sess.cwd)
550 sess.mu.Unlock()
551 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
552 })
553 }
554}
555
556// TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment proves a local admitted
557// attach refreshes the session-bound variables for future PTY children while
558// the already-running PTY and ordinary variables are untouched.
559func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) {
560 firstEnv := []string{"MARK=first", "WAYLAND_DISPLAY=wayland-old"}
561 pty, release := newBlockingPTY(t)
562 defer release()
563 d := newTestDaemon(t, newFactorySeq(t, pty, newQuietPTY()), stubClock{})
564
565 create := protocol.Hello{
566 Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: defaultSize,
567 Env: firstEnv, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
568 }
569 createAdmission := ports.SessionAdmission{
570 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
571 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed, Name: "work", Env: firstEnv,
572 }
573 sess, _, err := d.route(create, &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: createAdmission, present: true})
574 require.NoError(t, err)
575 sess.mu.Lock()
576 require.Equal(t, firstEnv, sess.env)
577 existingPane := sess.tabs[0].focusedPane()
578 sess.mu.Unlock()
579
580 // A later attach refreshes only the session's future-child environment. A
581 // named admission authorizes creation only, so the attach rides a fresh
582 // exact admission for the live session's own lifecycle.
583 attachEnv := []string{"MARK=attach", "WAYLAND_DISPLAY=wayland-new"}
584 target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
585 attach := protocol.Hello{
586 Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, Size: defaultSize,
587 ExactTarget: &target, Env: attachEnv, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
588 }
589 exactAdmission := ports.SessionAdmission{
590 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
591 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Target: target, Env: attachEnv,
592 }
593 _, _, err = d.route(attach, &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: exactAdmission, present: true})
594 require.NoError(t, err)
595
596 sess.mu.Lock()
597 require.Equal(t, []string{"MARK=first", "WAYLAND_DISPLAY=wayland-new"}, sess.env, "a later client-owned attach refreshes only session-bound variables")
598 require.Same(t, existingPane, sess.tabs[0].focusedPane(), "the existing PTY is untouched")
599 sess.mu.Unlock()
600
601 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
602}
603
604// TestRouteLocalAttachAdoptsClientEnvironmentForRestoredSession proves a
605// session restored at daemon startup, whose environment came from the daemon
606// process, takes the first local client's environment wholesale and only
607// refreshes session-bound variables afterwards.
608func TestRouteLocalAttachAdoptsClientEnvironmentForRestoredSession(t *testing.T) {
609 d := newTestDaemon(t, nil, stubClock{})
610 sess := addControlSession(d, "work", "tab-1", "pane-1")
611 sess.ephemeral = false
612 sess.incarnation = newTestLifecycle(t)
613 sess.mu.Lock()
614 sess.env = []string{"SHELL=/bin/sh", "PATH=/daemon"}
615 sess.envProvisional = true
616 sess.mu.Unlock()
617
618 target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
619 attach := func(env []string) {
620 t.Helper()
621 hello := protocol.Hello{
622 Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, Size: defaultSize,
623 ExactTarget: &target, Env: env, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
624 }
625 admission := ports.SessionAdmission{
626 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
627 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Target: target, Env: env,
628 }
629 tr := &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: admission, present: true}
630 _, ac, err := d.route(hello, tr)
631 require.NoError(t, err)
632 d.clientGone(sess, ac, tr, false)
633 }
634
635 attach([]string{"SHELL=/usr/bin/fish", "PATH=/client", "WAYLAND_DISPLAY=wayland-1"})
636 sess.mu.Lock()
637 require.Equal(t, []string{"SHELL=/usr/bin/fish", "PATH=/client", "WAYLAND_DISPLAY=wayland-1"}, sess.env)
638 require.False(t, sess.envProvisional)
639 sess.mu.Unlock()
640
641 attach([]string{"SHELL=/bin/bash", "PATH=/other", "WAYLAND_DISPLAY=wayland-2"})
642 sess.mu.Lock()
643 require.Equal(t, []string{"SHELL=/usr/bin/fish", "PATH=/client", "WAYLAND_DISPLAY=wayland-2"}, sess.env)
644 sess.mu.Unlock()
645}
646
647// TestRouteRemoteExactAttachPreservesSessionEnvironment proves a remote exact
648// attach never rewrites the live session's environment or working directory:
649// the daemon-owned session keeps what it was created with, and only a
650// client-owned local attach refreshes future-child state.
651func TestRouteRemoteExactAttachPreservesSessionEnvironment(t *testing.T) {
652 d := newTestDaemon(t, nil, stubClock{})
653 sess := addControlSession(d, "work", "tab-1", "pane-1")
654 sess.ephemeral = false
655 sess.incarnation = newTestLifecycle(t)
656 sess.mu.Lock()
657 sess.env = []string{"SESSION=owned"}
658 sess.mu.Unlock()
659
660 target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
661 hello := protocol.Hello{
662 Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName,
663 Size: defaultSize, ExactTarget: &target,
664 Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
665 }
666 admission := ports.SessionAdmission{
667 Origin: ports.SessionOriginRemote, Policy: admissionTestPolicy(ports.SessionOriginRemote),
668 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Target: target,
669 }
670 tr := &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: admission, present: true}
671 routed, ac, err := d.route(hello, tr)
672 require.NoError(t, err)
673 require.Same(t, sess, routed)
674 sess.mu.Lock()
675 require.Equal(t, []string{"SESSION=owned"}, sess.env, "a remote attach preserves the session environment")
676 require.Equal(t, "/tmp/work", sess.cwd, "a remote attach preserves the session working directory")
677 sess.mu.Unlock()
678 d.clientGone(sess, ac, tr, false)
679}
680
681// TestRouteResumeRejectsMismatchedAdmittedTargetBeforeClaim proves an admitted
682// resume whose target does not name the exact lifecycle the token owns is
683// refused before any claim or transport replacement: the parked attachment
684// remains unclaimed and its transport is preserved.
685func TestRouteResumeRejectsMismatchedAdmittedTargetBeforeClaim(t *testing.T) {
686 remotePolicy := admissionTestPolicy(ports.SessionOriginRemote)
687
688 tests := []struct {
689 name string
690 target func(sess *session) protocol.ExactSessionTarget
691 }{
692 {name: "wrong lifecycle", target: func(sess *session) protocol.ExactSessionTarget {
693 lifecycle := sess.incarnation
694 lifecycle[0]++
695 return protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: sess.name}
696 }},
697 {name: "wrong name", target: func(sess *session) protocol.ExactSessionTarget {
698 return protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: "other"}
699 }},
700 }
701 for _, tt := range tests {
702 t.Run(tt.name, func(t *testing.T) {
703 pty, release := newBlockingPTY(t)
704 defer release()
705 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
706 oldTransport := &closeTrackingTransport{}
707 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTransport)
708 require.NoError(t, err)
709 token := ac.resumeToken
710 d.clientGone(sess, ac, oldTransport, false)
711
712 target := tt.target(sess)
713 resume := protocol.Hello{
714 Version: protocol.Version, Intent: protocol.IntentResume, Name: target.SessionName,
715 ResumeToken: token, Size: defaultSize, ExactTarget: &target,
716 Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
717 }
718 admission := ports.SessionAdmission{
719 Origin: ports.SessionOriginRemote, Policy: remotePolicy,
720 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Target: target,
721 }
722 tr := &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: admission, present: true}
723 _, _, err = d.route(resume, tr)
724 var protocolErr *protoErr
725 require.ErrorAs(t, err, &protocolErr)
726 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
727
728 d.mu.Lock()
729 parked := d.resume.parked[token]
730 claimed := parked != nil && parked.claimed
731 d.mu.Unlock()
732 require.NotNil(t, parked, "a mismatched admitted target must not remove the parked credential")
733 require.False(t, claimed, "a mismatched admitted target must not claim the parked attachment")
734 require.Same(t, oldTransport, ac.transport(), "a mismatched admitted target must not replace the transport")
735
736 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
737 })
738 }
739}