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's environment for future PTY children while the
558// already-running PTY is untouched, which is the existing shared-session
559// semantics the admission must preserve.
560func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) {
561 firstEnv := []string{"MARK=first"}
562 pty, release := newBlockingPTY(t)
563 defer release()
564 d := newTestDaemon(t, newFactorySeq(t, pty, newQuietPTY()), stubClock{})
565
566 create := protocol.Hello{
567 Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: defaultSize,
568 Env: firstEnv, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
569 }
570 createAdmission := ports.SessionAdmission{
571 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
572 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed, Name: "work", Env: firstEnv,
573 }
574 sess, _, err := d.route(create, &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: createAdmission, present: true})
575 require.NoError(t, err)
576 sess.mu.Lock()
577 require.Equal(t, firstEnv, sess.env)
578 existingPane := sess.tabs[0].focusedPane()
579 sess.mu.Unlock()
580
581 // A later attach refreshes only the session's future-child environment. A
582 // named admission authorizes creation only, so the attach rides a fresh
583 // exact admission for the live session's own lifecycle.
584 attachEnv := []string{"MARK=attach"}
585 target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
586 attach := protocol.Hello{
587 Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, Size: defaultSize,
588 ExactTarget: &target, Env: attachEnv, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
589 }
590 exactAdmission := ports.SessionAdmission{
591 Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
592 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Target: target, Env: attachEnv,
593 }
594 _, _, err = d.route(attach, &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: exactAdmission, present: true})
595 require.NoError(t, err)
596
597 sess.mu.Lock()
598 require.Equal(t, attachEnv, sess.env, "a later client-owned attach refreshes future child environment")
599 require.Same(t, existingPane, sess.tabs[0].focusedPane(), "the existing PTY is untouched")
600 sess.mu.Unlock()
601
602 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
603}
604
605// TestRouteRemoteExactAttachPreservesSessionEnvironment proves a remote exact
606// attach never rewrites the live session's environment or working directory:
607// the daemon-owned session keeps what it was created with, and only a
608// client-owned local attach refreshes future-child state.
609func TestRouteRemoteExactAttachPreservesSessionEnvironment(t *testing.T) {
610 d := newTestDaemon(t, nil, stubClock{})
611 sess := addControlSession(d, "work", "tab-1", "pane-1")
612 sess.ephemeral = false
613 sess.incarnation = newTestLifecycle(t)
614 sess.mu.Lock()
615 sess.env = []string{"SESSION=owned"}
616 sess.mu.Unlock()
617
618 target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
619 hello := protocol.Hello{
620 Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName,
621 Size: defaultSize, ExactTarget: &target,
622 Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
623 }
624 admission := ports.SessionAdmission{
625 Origin: ports.SessionOriginRemote, Policy: admissionTestPolicy(ports.SessionOriginRemote),
626 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Target: target,
627 }
628 tr := &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: admission, present: true}
629 routed, ac, err := d.route(hello, tr)
630 require.NoError(t, err)
631 require.Same(t, sess, routed)
632 sess.mu.Lock()
633 require.Equal(t, []string{"SESSION=owned"}, sess.env, "a remote attach preserves the session environment")
634 require.Equal(t, "/tmp/work", sess.cwd, "a remote attach preserves the session working directory")
635 sess.mu.Unlock()
636 d.clientGone(sess, ac, tr, false)
637}
638
639// TestRouteResumeRejectsMismatchedAdmittedTargetBeforeClaim proves an admitted
640// resume whose target does not name the exact lifecycle the token owns is
641// refused before any claim or transport replacement: the parked attachment
642// remains unclaimed and its transport is preserved.
643func TestRouteResumeRejectsMismatchedAdmittedTargetBeforeClaim(t *testing.T) {
644 remotePolicy := admissionTestPolicy(ports.SessionOriginRemote)
645
646 tests := []struct {
647 name string
648 target func(sess *session) protocol.ExactSessionTarget
649 }{
650 {name: "wrong lifecycle", target: func(sess *session) protocol.ExactSessionTarget {
651 lifecycle := sess.incarnation
652 lifecycle[0]++
653 return protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: sess.name}
654 }},
655 {name: "wrong name", target: func(sess *session) protocol.ExactSessionTarget {
656 return protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: "other"}
657 }},
658 }
659 for _, tt := range tests {
660 t.Run(tt.name, func(t *testing.T) {
661 pty, release := newBlockingPTY(t)
662 defer release()
663 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
664 oldTransport := &closeTrackingTransport{}
665 sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTransport)
666 require.NoError(t, err)
667 token := ac.resumeToken
668 d.clientGone(sess, ac, oldTransport, false)
669
670 target := tt.target(sess)
671 resume := protocol.Hello{
672 Version: protocol.Version, Intent: protocol.IntentResume, Name: target.SessionName,
673 ResumeToken: token, Size: defaultSize, ExactTarget: &target,
674 Remote: true, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
675 }
676 admission := ports.SessionAdmission{
677 Origin: ports.SessionOriginRemote, Policy: remotePolicy,
678 Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Target: target,
679 }
680 tr := &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: admission, present: true}
681 _, _, err = d.route(resume, tr)
682 var protocolErr *protoErr
683 require.ErrorAs(t, err, &protocolErr)
684 require.Equal(t, protocol.ErrNoSuchTarget, protocolErr.code)
685
686 d.mu.Lock()
687 parked := d.resume.parked[token]
688 claimed := parked != nil && parked.claimed
689 d.mu.Unlock()
690 require.NotNil(t, parked, "a mismatched admitted target must not remove the parked credential")
691 require.False(t, claimed, "a mismatched admitted target must not claim the parked attachment")
692 require.Same(t, oldTransport, ac.transport(), "a mismatched admitted target must not replace the transport")
693
694 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
695 })
696 }
697}