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'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}

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