v0.7.0 4c16dd8

session_admission_test.go RawHistoryFolder

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}

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