detached 8a884f0

feat(env): refresh session environment on attach and add vev env (#305)

* feat(env): refresh session-bound variables on attach and add vev env

A local attach, resume, or picker handoff no longer replaces the session
environment wholesale. Only desktop variables (WAYLAND_DISPLAY, DISPLAY,
DBUS_SESSION_BUS_ADDRESS, XDG_*) are updated when the client has them,
and login variables (SSH_AUTH_SOCK, SSH_AGENT_PID, SSH_CONNECTION, ...)
follow the latest client exactly. SHELL, PATH, and other variables keep
their creation values. Sessions restored at daemon start adopt the first
local client environment wholesale.

`vev cmd env [--json]` exposes those variables as data, and
`vev env <fish|sh>` renders them as shell code for prompt hooks.

* fix(daemon): refresh handoff targets from the switching attachment env

Session handoffs (picker, palette, same-peer switch, new-session and
stopped-session switches) refreshed the target session from the source
session environment, which belongs to whichever client attached to the
source last. A different local client, or a remote attachment, could
push that environment into the target.

Each attachment now records its own client-owned Hello environment
(nil when the daemon owns it, cleared by a remote resume), and every
handoff refreshes the target from that environment only. The request
flag is renamed to refreshTargetEnvironment to match.

* feat(daemon): move display variables as one group on attach

Desktop variables were refreshed one by one and never removed, so
attaching from a second compositor or an X11-only desktop left the
previous WAYLAND_DISPLAY or DISPLAY behind, and an SSH attach with
XDG_SESSION_TYPE=tty mixed console values into a graphical session.

WAYLAND_DISPLAY, DISPLAY, XAUTHORITY, XDG_CURRENT_DESKTOP, and
XDG_SESSION_* now form a display group that a local client with a
non-empty WAYLAND_DISPLAY or DISPLAY replaces as a whole. Console and
SSH clients, including X-forwarded ones, leave it untouched.
DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR stay per-user values that
are set but never removed. `vev cmd env --json` unsets missing display
members when the session has a display.

* fix(env): apply attach environment in publication and harden vev env

Attach and resume no longer rewrite the session environment before the
attachment transition: every non-preserved publication (attach, resume,
picker, palette, same-peer switch, followed move) adopts the attaching
client's own environment, so a failed transition changes nothing and no
handoff path can forget the refresh.

A provisional (daemon-owned) environment now also comes from daemon-owned
resumes of stopped sessions and carries over to sessions created or
resumed from a provisional one. A client without its own display that
first adopts such a session keeps the session's display group. Refresh
updates variables in place, so a session refreshed from its creating
environment stays byte-identical.

The fish renderer unsets with `set -e -g`, which no longer erases the
user's universal variables. `vev cmd` and `vev env` share their
production wiring, and the help, usage line, docs, and registry tests
cover the `env` command.

* docs(daemon): list every source of a provisional session environment
commit
8a884f04d251b3b3425bb6e4d12faeff54b23639
author
bnema · · 2026-10-03 14:10 UTC
committer
GitHub · · 2026-10-03 14:10 UTC
parent
c37833b
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
29 files · +1292 -79
M README.md +3 -0
@@ -114,10 +114,13 @@ Full list: [configuration](docs/configuration.md).
114114 vev cmd split-right
115115 vev cmd toast -l warn "build failed"
116116 vev cmd list-panes --json
117+vev cmd env --json
117118 ```
118119
119120 Use `vev cmd --help` for all commands. For headless capture and input, see [UI driver](docs/ui-driver.md).
120121
122+To refresh `WAYLAND_DISPLAY`, `SSH_AUTH_SOCK`, and other desktop variables in a running shell after you attach from another place, hook `vev env fish | source` (or `eval "$(vev env sh)"`) into your prompt. See [environment](docs/terminal.md#environment).
123+
121124 ## Documentation
122125
123126 - [Configuration](docs/configuration.md): bindings, palette, theme, overlays, and bar anchors
@@ -114,10 +114,13 @@ Full list: [configuration](docs/configuration.md).
114 vev cmd split-right114 vev cmd split-right
115 vev cmd toast -l warn "build failed"115 vev cmd toast -l warn "build failed"
116 vev cmd list-panes --json116 vev cmd list-panes --json
117+vev cmd env --json
117 ```118 ```
118 119
119 Use `vev cmd --help` for all commands. For headless capture and input, see [UI driver](docs/ui-driver.md).120 Use `vev cmd --help` for all commands. For headless capture and input, see [UI driver](docs/ui-driver.md).
120 121
122+To refresh `WAYLAND_DISPLAY`, `SSH_AUTH_SOCK`, and other desktop variables in a running shell after you attach from another place, hook `vev env fish | source` (or `eval "$(vev env sh)"`) into your prompt. See [environment](docs/terminal.md#environment).
123+
121 ## Documentation124 ## Documentation
122 125
123 - [Configuration](docs/configuration.md): bindings, palette, theme, overlays, and bar anchors126 - [Configuration](docs/configuration.md): bindings, palette, theme, overlays, and bar anchors
M docs/terminal.md +28 -1
@@ -59,7 +59,34 @@ When your outer terminal supports the kitty keyboard protocol (kitty, foot, ghos
5959
6060 ## Environment
6161
62-- New panes inherit the environment of the client that last attached. Running processes keep theirs.
62+- A session keeps the environment it was created with, including `SHELL`, `PATH`, and `HOME`. A session restored after a daemon restart takes the environment of the first local client that attaches.
63+- Desktop and login variables follow the client that last attached:
64+ - `WAYLAND_DISPLAY`, `DISPLAY`, `XAUTHORITY`, `XDG_CURRENT_DESKTOP`, and `XDG_SESSION_*` move as one group. A client with a non-empty `WAYLAND_DISPLAY` or `DISPLAY` replaces the whole group, so switching to another compositor or an X11-only desktop drops the old values. A client without a display (console) or connected over SSH leaves the group alone, even with X forwarding: after `ssh -X`, set `DISPLAY` yourself in the panes that need it.
65+ - `DBUS_SESSION_BUS_ADDRESS` and `XDG_RUNTIME_DIR` update when the client has them and are never removed. With systemd, all your graphical sessions share them.
66+ - `SSH_AUTH_SOCK`, `SSH_AGENT_PID`, `SSH_CONNECTION`, `SSH_CLIENT`, and `SSH_TTY` always match the last client and are removed when it has none.
67+- New panes get these values. Running processes keep their own environment; a shell pulls the current values with `vev env <fish|sh>` from its prompt hook:
68+
69+ ```fish
70+ # ~/.config/fish/conf.d/vev.fish
71+ if set -q VEV
72+ function __vev_env --on-event fish_prompt
73+ vev env fish 2>/dev/null | source
74+ end
75+ end
76+ ```
77+
78+ ```zsh
79+ # ~/.zshrc (bash: add the eval to PROMPT_COMMAND)
80+ if [[ -n $VEV ]]; then
81+ autoload -Uz add-zsh-hook
82+ __vev_env() { eval "$(vev env sh 2>/dev/null)" }
83+ add-zsh-hook precmd __vev_env
84+ fi
85+ ```
86+
87+ The hook overrides values you set by hand in that shell, such as `SSH_AUTH_SOCK` after `eval (ssh-agent)`.
88+- `vev cmd env` lists the set variables as `KEY=value` for reading. Scripts should use `vev cmd env --json`, which also lists the variables to unset as `null`.
89+- In fish, the hook erases only global variables; a universal variable (`set -U`) with the same name shows through when the session has no value for it.
6390 - vev always sets `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`. `SHELL` picks the shell.
6491 - A remote session opened from the picker uses the remote host's own environment, not yours. This keeps `HOME`, `PATH`, and `SHELL` correct on that host.
6592
@@ -59,7 +59,34 @@ When your outer terminal supports the kitty keyboard protocol (kitty, foot, ghos
59 59
60 ## Environment60 ## Environment
61 61
62-- New panes inherit the environment of the client that last attached. Running processes keep theirs.62+- A session keeps the environment it was created with, including `SHELL`, `PATH`, and `HOME`. A session restored after a daemon restart takes the environment of the first local client that attaches.
63+- Desktop and login variables follow the client that last attached:
64+ - `WAYLAND_DISPLAY`, `DISPLAY`, `XAUTHORITY`, `XDG_CURRENT_DESKTOP`, and `XDG_SESSION_*` move as one group. A client with a non-empty `WAYLAND_DISPLAY` or `DISPLAY` replaces the whole group, so switching to another compositor or an X11-only desktop drops the old values. A client without a display (console) or connected over SSH leaves the group alone, even with X forwarding: after `ssh -X`, set `DISPLAY` yourself in the panes that need it.
65+ - `DBUS_SESSION_BUS_ADDRESS` and `XDG_RUNTIME_DIR` update when the client has them and are never removed. With systemd, all your graphical sessions share them.
66+ - `SSH_AUTH_SOCK`, `SSH_AGENT_PID`, `SSH_CONNECTION`, `SSH_CLIENT`, and `SSH_TTY` always match the last client and are removed when it has none.
67+- New panes get these values. Running processes keep their own environment; a shell pulls the current values with `vev env <fish|sh>` from its prompt hook:
68+
69+ ```fish
70+ # ~/.config/fish/conf.d/vev.fish
71+ if set -q VEV
72+ function __vev_env --on-event fish_prompt
73+ vev env fish 2>/dev/null | source
74+ end
75+ end
76+ ```
77+
78+ ```zsh
79+ # ~/.zshrc (bash: add the eval to PROMPT_COMMAND)
80+ if [[ -n $VEV ]]; then
81+ autoload -Uz add-zsh-hook
82+ __vev_env() { eval "$(vev env sh 2>/dev/null)" }
83+ add-zsh-hook precmd __vev_env
84+ fi
85+ ```
86+
87+ The hook overrides values you set by hand in that shell, such as `SSH_AUTH_SOCK` after `eval (ssh-agent)`.
88+- `vev cmd env` lists the set variables as `KEY=value` for reading. Scripts should use `vev cmd env --json`, which also lists the variables to unset as `null`.
89+- In fish, the hook erases only global variables; a universal variable (`set -U`) with the same name shows through when the session has no value for it.
63 - vev always sets `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`. `SHELL` picks the shell.90 - vev always sets `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`. `SHELL` picks the shell.
64 - A remote session opened from the picker uses the remote host's own environment, not yours. This keeps `HOME`, `PATH`, and `SHELL` correct on that host.91 - A remote session opened from the picker uses the remote host's own environment, not yours. This keeps `HOME`, `PATH`, and `SHELL` correct on that host.
65 92
M internal/app/cmd.go +7 -2
@@ -162,7 +162,12 @@ type cmdDeps struct {
162162 }
163163
164164 func runCmd(ctx context.Context, invocation cmdInvocation) error {
165- return runCmdWithDeps(ctx, invocation, cmdDeps{
165+ return runCmdWithDeps(ctx, invocation, productionCmdDeps())
166+}
167+
168+// productionCmdDeps wires control commands to the real broker and daemon.
169+func productionCmdDeps() cmdDeps {
170+ return cmdDeps{
166171 stdout: os.Stdout,
167172 getenv: os.Getenv,
168173 connect: connectProductionBroker,
@@ -171,7 +176,7 @@ func runCmd(ctx context.Context, invocation cmdInvocation) error {
171176 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
172177 },
173178 clock: clock.New(),
174- })
179+ }
175180 }
176181
177182 func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
@@ -162,7 +162,12 @@ type cmdDeps struct {
162 }162 }
163 163
164 func runCmd(ctx context.Context, invocation cmdInvocation) error {164 func runCmd(ctx context.Context, invocation cmdInvocation) error {
165- return runCmdWithDeps(ctx, invocation, cmdDeps{165+ return runCmdWithDeps(ctx, invocation, productionCmdDeps())
166+}
167+
168+// productionCmdDeps wires control commands to the real broker and daemon.
169+func productionCmdDeps() cmdDeps {
170+ return cmdDeps{
166 stdout: os.Stdout,171 stdout: os.Stdout,
167 getenv: os.Getenv,172 getenv: os.Getenv,
168 connect: connectProductionBroker,173 connect: connectProductionBroker,
@@ -171,7 +176,7 @@ func runCmd(ctx context.Context, invocation cmdInvocation) error {
171 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)176 return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff)
172 },177 },
173 clock: clock.New(),178 clock: clock.New(),
174- })179+ }
175 }180 }
176 181
177 func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {182 func runCmdWithDeps(ctx context.Context, invocation cmdInvocation, deps cmdDeps) error {
M internal/app/daemon_integration_test.go +18 -5
@@ -520,10 +520,19 @@ func assertChildEnvironment(t *testing.T, tr ports.ClientConnection, p *typedPum
520520 }
521521 }
522522
523+// displayShellFixture names each shell after the WAYLAND_DISPLAY it started
524+// with, so a test can tell which session-bound environment a PTY received.
525+func displayShellFixture(t *testing.T) string {
526+ t.Helper()
527+ path := filepath.Join(t.TempDir(), "shell")
528+ require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nprintf 'SHELL_COMMAND=%s\\n' \"${WAYLAND_DISPLAY#wayland-}\"\nexec /bin/sh\n"), 0o700))
529+ return path
530+}
531+
523532 func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) {
524533 sz := domain.Size{Cols: 80, Rows: 24}
525534 dir, _ := startDaemon(t)
526- firstShell := shellFixture(t, "first")
535+ firstShell := displayShellFixture(t)
527536 secondShell := shellFixture(t, "second")
528537 firstEnv := []string{
529538 "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",
@@ -548,17 +557,21 @@ func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) {
548557 require.NoError(t, tr2.SendClient(protocol.Input{Data: []byte("\x1b ")}))
549558 awaitText(t, p2, sz, "Commands")
550559 require.NoError(t, tr2.SendClient(protocol.Input{Data: []byte("CNT\r")}))
560+ // The new tab keeps the creator's shell and ordinary variables; only the
561+ // session-bound desktop variables follow the latest attach.
551562 awaitText(t, p2, sz, "SHELL_COMMAND=second")
552- assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second")
563+ assertChildEnvironment(t, tr2, p2, sz, "first", firstShell, "/run/second", "wayland-second")
553564 }
554565
555566 func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T) {
556567 sz := domain.Size{Cols: 80, Rows: 24}
557568 dir, _ := startDaemon(t)
558- firstShell := shellFixture(t, "first")
559569 const sharedOutput = "SECOND_SHARED_PTY_OUTPUT"
560- secondShell := filepath.Join(t.TempDir(), "second-shell")
561- require.NoError(t, os.WriteFile(secondShell, []byte("#!/bin/sh\nprintf 'SHELL_COMMAND=second\\n'\nIFS= read -r _\nprintf '"+sharedOutput+"\\n'\nexec /bin/sh\n"), 0o700))
570+ // The session keeps its creator's SHELL, so one script serves both tabs and
571+ // gates only the PTY opened after the second attach refreshed the display.
572+ firstShell := filepath.Join(t.TempDir(), "shell")
573+ require.NoError(t, os.WriteFile(firstShell, []byte("#!/bin/sh\nif [ \"$WAYLAND_DISPLAY\" = wayland-second ]; then\nprintf 'SHELL_COMMAND=second\\n'\nIFS= read -r _\nprintf '"+sharedOutput+"\\n'\nelse\nprintf 'SHELL_COMMAND=first\\n'\nfi\nexec /bin/sh\n"), 0o700))
574+ secondShell := shellFixture(t, "unused")
562575 firstEnv := []string{
563576 "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",
564577 "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",
@@ -520,10 +520,19 @@ func assertChildEnvironment(t *testing.T, tr ports.ClientConnection, p *typedPum
520 }520 }
521 }521 }
522 522
523+// displayShellFixture names each shell after the WAYLAND_DISPLAY it started
524+// with, so a test can tell which session-bound environment a PTY received.
525+func displayShellFixture(t *testing.T) string {
526+ t.Helper()
527+ path := filepath.Join(t.TempDir(), "shell")
528+ require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\nprintf 'SHELL_COMMAND=%s\\n' \"${WAYLAND_DISPLAY#wayland-}\"\nexec /bin/sh\n"), 0o700))
529+ return path
530+}
531+
523 func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) {532 func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) {
524 sz := domain.Size{Cols: 80, Rows: 24}533 sz := domain.Size{Cols: 80, Rows: 24}
525 dir, _ := startDaemon(t)534 dir, _ := startDaemon(t)
526- firstShell := shellFixture(t, "first")535+ firstShell := displayShellFixture(t)
527 secondShell := shellFixture(t, "second")536 secondShell := shellFixture(t, "second")
528 firstEnv := []string{537 firstEnv := []string{
529 "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",538 "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",
@@ -548,17 +557,21 @@ func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) {
548 require.NoError(t, tr2.SendClient(protocol.Input{Data: []byte("\x1b ")}))557 require.NoError(t, tr2.SendClient(protocol.Input{Data: []byte("\x1b ")}))
549 awaitText(t, p2, sz, "Commands")558 awaitText(t, p2, sz, "Commands")
550 require.NoError(t, tr2.SendClient(protocol.Input{Data: []byte("CNT\r")}))559 require.NoError(t, tr2.SendClient(protocol.Input{Data: []byte("CNT\r")}))
560+ // The new tab keeps the creator's shell and ordinary variables; only the
561+ // session-bound desktop variables follow the latest attach.
551 awaitText(t, p2, sz, "SHELL_COMMAND=second")562 awaitText(t, p2, sz, "SHELL_COMMAND=second")
552- assertChildEnvironment(t, tr2, p2, sz, "second", secondShell, "/run/second", "wayland-second")563+ assertChildEnvironment(t, tr2, p2, sz, "first", firstShell, "/run/second", "wayland-second")
553 }564 }
554 565
555 func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T) {566 func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T) {
556 sz := domain.Size{Cols: 80, Rows: 24}567 sz := domain.Size{Cols: 80, Rows: 24}
557 dir, _ := startDaemon(t)568 dir, _ := startDaemon(t)
558- firstShell := shellFixture(t, "first")
559 const sharedOutput = "SECOND_SHARED_PTY_OUTPUT"569 const sharedOutput = "SECOND_SHARED_PTY_OUTPUT"
560- secondShell := filepath.Join(t.TempDir(), "second-shell")570+ // The session keeps its creator's SHELL, so one script serves both tabs and
561- require.NoError(t, os.WriteFile(secondShell, []byte("#!/bin/sh\nprintf 'SHELL_COMMAND=second\\n'\nIFS= read -r _\nprintf '"+sharedOutput+"\\n'\nexec /bin/sh\n"), 0o700))571+ // gates only the PTY opened after the second attach refreshed the display.
572+ firstShell := filepath.Join(t.TempDir(), "shell")
573+ require.NoError(t, os.WriteFile(firstShell, []byte("#!/bin/sh\nif [ \"$WAYLAND_DISPLAY\" = wayland-second ]; then\nprintf 'SHELL_COMMAND=second\\n'\nIFS= read -r _\nprintf '"+sharedOutput+"\\n'\nelse\nprintf 'SHELL_COMMAND=first\\n'\nfi\nexec /bin/sh\n"), 0o700))
574+ secondShell := shellFixture(t, "unused")
562 firstEnv := []string{575 firstEnv := []string{
563 "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",576 "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first",
564 "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",577 "TERM=client", "COLORTERM=client", "TERM_PROGRAM=client", "VEV=client",
A internal/app/env.go +138 -0
@@ -0,0 +1,138 @@
1+package app
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "sort"
9+ "strings"
10+)
11+
12+const envHelp = `usage: vev env <fish|sh> [-s <session>]
13+
14+Print shell code that applies the session's current desktop and login
15+variables (WAYLAND_DISPLAY, DBUS_SESSION_BUS_ADDRESS, SSH_AUTH_SOCK, ...) to a
16+running shell. Inside a pane the current session is used; outside a pane,
17+-s <session> is required.
18+
19+ fish vev env fish | source
20+ sh eval "$(vev env sh)" (bash, zsh, and other POSIX shells)
21+
22+For the same values as data, use vev cmd env [--json].`
23+
24+// envInvocation is the shell-integration front end for the `env` control
25+// command. It only renders shell code; `vev cmd env` owns the data API.
26+type envInvocation struct {
27+ shell string
28+ session string
29+ help bool
30+}
31+
32+func parseEnvArgs(args []string) (envInvocation, error) {
33+ var invocation envInvocation
34+ for index := 0; index < len(args); index++ {
35+ switch arg := args[index]; arg {
36+ case "--help", "-h":
37+ invocation.help = true
38+ case "-s":
39+ if index+1 >= len(args) || args[index+1] == "" {
40+ return envInvocation{}, usagef("`env -s` requires a session name")
41+ }
42+ invocation.session = args[index+1]
43+ index++
44+ case "fish", "sh":
45+ if invocation.shell != "" {
46+ return envInvocation{}, usagef("`env` takes one shell: fish or sh")
47+ }
48+ invocation.shell = arg
49+ default:
50+ if strings.HasPrefix(arg, "-") {
51+ return envInvocation{}, usagef("unknown flag %q for `env`", arg)
52+ }
53+ return envInvocation{}, usagef("unsupported shell %q for `env`; use fish or sh", arg)
54+ }
55+ }
56+ if invocation.shell == "" && !invocation.help {
57+ return envInvocation{}, usagef("`env` requires a shell: fish or sh")
58+ }
59+ return invocation, nil
60+}
61+
62+func runEnv(ctx context.Context, invocation envInvocation) error {
63+ return runEnvWithDeps(ctx, invocation, productionCmdDeps())
64+}
65+
66+func runEnvWithDeps(ctx context.Context, invocation envInvocation, deps cmdDeps) error {
67+ if invocation.help {
68+ _, err := fmt.Fprintln(deps.stdout, envHelp)
69+ return err
70+ }
71+ // The output is evaluated by a shell; never guess which session it follows.
72+ if _, inPane := parseVEVEnv(deps.getenv("VEV")); !inPane && invocation.session == "" {
73+ return usagef("`env` requires running inside a vev pane or -s <session>")
74+ }
75+ var captured strings.Builder
76+ dataDeps := deps
77+ dataDeps.stdout = &captured
78+ if err := runCmdWithDeps(ctx, cmdInvocation{slug: "env", session: invocation.session, jsonOut: true}, dataDeps); err != nil {
79+ return err
80+ }
81+ return writeShellEnvironment(deps.stdout, invocation.shell, captured.String())
82+}
83+
84+// writeShellEnvironment converts the `env --json` result into code that sets
85+// or unsets each variable in the given shell. Nothing is written on error.
86+func writeShellEnvironment(out io.Writer, shell, encoded string) error {
87+ var values map[string]*string
88+ if err := json.Unmarshal([]byte(encoded), &values); err != nil {
89+ return fmt.Errorf("decoding session environment: %w", err)
90+ }
91+ keys := make([]string, 0, len(values))
92+ for key := range values {
93+ if !isEnvironmentName(key) {
94+ return fmt.Errorf("invalid session environment name %q", key)
95+ }
96+ keys = append(keys, key)
97+ }
98+ sort.Strings(keys)
99+ var code strings.Builder
100+ for _, key := range keys {
101+ value := values[key]
102+ switch {
103+ case shell == "fish" && value == nil:
104+ // -g: without a global, a bare `set -e` erases the user's
105+ // universal variable for every fish session.
106+ code.WriteString("set -e -g " + key + "\n")
107+ case shell == "fish":
108+ code.WriteString("set -gx " + key + " " + fishQuote(*value) + "\n")
109+ case value == nil:
110+ code.WriteString("unset " + key + "\n")
111+ default:
112+ code.WriteString("export " + key + "=" + shQuote(*value) + "\n")
113+ }
114+ }
115+ _, err := io.WriteString(out, code.String())
116+ return err
117+}
118+
119+func isEnvironmentName(name string) bool {
120+ if name == "" || name[0] >= '0' && name[0] <= '9' {
121+ return false
122+ }
123+ for i := 0; i < len(name); i++ {
124+ c := name[i]
125+ if c != '_' && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && (c < '0' || c > '9') {
126+ return false
127+ }
128+ }
129+ return true
130+}
131+
132+func shQuote(value string) string {
133+ return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
134+}
135+
136+func fishQuote(value string) string {
137+ return "'" + strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(value) + "'"
138+}
@@ -0,0 +1,138 @@
1+package app
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "sort"
9+ "strings"
10+)
11+
12+const envHelp = `usage: vev env <fish|sh> [-s <session>]
13+
14+Print shell code that applies the session's current desktop and login
15+variables (WAYLAND_DISPLAY, DBUS_SESSION_BUS_ADDRESS, SSH_AUTH_SOCK, ...) to a
16+running shell. Inside a pane the current session is used; outside a pane,
17+-s <session> is required.
18+
19+ fish vev env fish | source
20+ sh eval "$(vev env sh)" (bash, zsh, and other POSIX shells)
21+
22+For the same values as data, use vev cmd env [--json].`
23+
24+// envInvocation is the shell-integration front end for the `env` control
25+// command. It only renders shell code; `vev cmd env` owns the data API.
26+type envInvocation struct {
27+ shell string
28+ session string
29+ help bool
30+}
31+
32+func parseEnvArgs(args []string) (envInvocation, error) {
33+ var invocation envInvocation
34+ for index := 0; index < len(args); index++ {
35+ switch arg := args[index]; arg {
36+ case "--help", "-h":
37+ invocation.help = true
38+ case "-s":
39+ if index+1 >= len(args) || args[index+1] == "" {
40+ return envInvocation{}, usagef("`env -s` requires a session name")
41+ }
42+ invocation.session = args[index+1]
43+ index++
44+ case "fish", "sh":
45+ if invocation.shell != "" {
46+ return envInvocation{}, usagef("`env` takes one shell: fish or sh")
47+ }
48+ invocation.shell = arg
49+ default:
50+ if strings.HasPrefix(arg, "-") {
51+ return envInvocation{}, usagef("unknown flag %q for `env`", arg)
52+ }
53+ return envInvocation{}, usagef("unsupported shell %q for `env`; use fish or sh", arg)
54+ }
55+ }
56+ if invocation.shell == "" && !invocation.help {
57+ return envInvocation{}, usagef("`env` requires a shell: fish or sh")
58+ }
59+ return invocation, nil
60+}
61+
62+func runEnv(ctx context.Context, invocation envInvocation) error {
63+ return runEnvWithDeps(ctx, invocation, productionCmdDeps())
64+}
65+
66+func runEnvWithDeps(ctx context.Context, invocation envInvocation, deps cmdDeps) error {
67+ if invocation.help {
68+ _, err := fmt.Fprintln(deps.stdout, envHelp)
69+ return err
70+ }
71+ // The output is evaluated by a shell; never guess which session it follows.
72+ if _, inPane := parseVEVEnv(deps.getenv("VEV")); !inPane && invocation.session == "" {
73+ return usagef("`env` requires running inside a vev pane or -s <session>")
74+ }
75+ var captured strings.Builder
76+ dataDeps := deps
77+ dataDeps.stdout = &captured
78+ if err := runCmdWithDeps(ctx, cmdInvocation{slug: "env", session: invocation.session, jsonOut: true}, dataDeps); err != nil {
79+ return err
80+ }
81+ return writeShellEnvironment(deps.stdout, invocation.shell, captured.String())
82+}
83+
84+// writeShellEnvironment converts the `env --json` result into code that sets
85+// or unsets each variable in the given shell. Nothing is written on error.
86+func writeShellEnvironment(out io.Writer, shell, encoded string) error {
87+ var values map[string]*string
88+ if err := json.Unmarshal([]byte(encoded), &values); err != nil {
89+ return fmt.Errorf("decoding session environment: %w", err)
90+ }
91+ keys := make([]string, 0, len(values))
92+ for key := range values {
93+ if !isEnvironmentName(key) {
94+ return fmt.Errorf("invalid session environment name %q", key)
95+ }
96+ keys = append(keys, key)
97+ }
98+ sort.Strings(keys)
99+ var code strings.Builder
100+ for _, key := range keys {
101+ value := values[key]
102+ switch {
103+ case shell == "fish" && value == nil:
104+ // -g: without a global, a bare `set -e` erases the user's
105+ // universal variable for every fish session.
106+ code.WriteString("set -e -g " + key + "\n")
107+ case shell == "fish":
108+ code.WriteString("set -gx " + key + " " + fishQuote(*value) + "\n")
109+ case value == nil:
110+ code.WriteString("unset " + key + "\n")
111+ default:
112+ code.WriteString("export " + key + "=" + shQuote(*value) + "\n")
113+ }
114+ }
115+ _, err := io.WriteString(out, code.String())
116+ return err
117+}
118+
119+func isEnvironmentName(name string) bool {
120+ if name == "" || name[0] >= '0' && name[0] <= '9' {
121+ return false
122+ }
123+ for i := 0; i < len(name); i++ {
124+ c := name[i]
125+ if c != '_' && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') && (c < '0' || c > '9') {
126+ return false
127+ }
128+ }
129+ return true
130+}
131+
132+func shQuote(value string) string {
133+ return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
134+}
135+
136+func fishQuote(value string) string {
137+ return "'" + strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(value) + "'"
138+}
A internal/app/env_test.go +187 -0
@@ -0,0 +1,187 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/wire"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestParseEnvArgs(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ args []string
19+ want envInvocation
20+ wantErr string
21+ }{
22+ {name: "fish", args: []string{"fish"}, want: envInvocation{shell: "fish"}},
23+ {name: "sh with session", args: []string{"sh", "-s", "work"}, want: envInvocation{shell: "sh", session: "work"}},
24+ {name: "session before shell", args: []string{"-s", "work", "fish"}, want: envInvocation{shell: "fish", session: "work"}},
25+ {name: "help without shell", args: []string{"--help"}, want: envInvocation{help: true}},
26+ {name: "missing shell", wantErr: "requires a shell"},
27+ {name: "two shells", args: []string{"fish", "sh"}, wantErr: "one shell"},
28+ {name: "same shell twice", args: []string{"fish", "fish"}, wantErr: "one shell"},
29+ {name: "unsupported shell", args: []string{"nu"}, wantErr: `unsupported shell "nu"`},
30+ {name: "json belongs to cmd env", args: []string{"--json"}, wantErr: `unknown flag "--json"`},
31+ {name: "old shell flag", args: []string{"--fish"}, wantErr: `unknown flag "--fish"`},
32+ {name: "missing session", args: []string{"fish", "-s"}, wantErr: "requires a session name"},
33+ }
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ got, err := parseEnvArgs(tt.args)
37+ if tt.wantErr != "" {
38+ require.ErrorContains(t, err, tt.wantErr)
39+ require.Equal(t, 2, exitCode(err))
40+ return
41+ }
42+ require.NoError(t, err)
43+ require.Equal(t, tt.want, got)
44+ })
45+ }
46+}
47+
48+func TestWriteShellEnvironment(t *testing.T) {
49+ encoded := `{"WAYLAND_DISPLAY":"wayland-1","SSH_AUTH_SOCK":null,"ODD":"it's \\ $HOME"}`
50+ tests := []struct {
51+ name string
52+ shell string
53+ encoded string
54+ want string
55+ wantErr string
56+ }{
57+ {
58+ name: "fish sets and erases",
59+ shell: "fish",
60+ encoded: encoded,
61+ want: "set -gx ODD 'it\\'s \\\\ $HOME'\nset -e -g SSH_AUTH_SOCK\nset -gx WAYLAND_DISPLAY 'wayland-1'\n",
62+ },
63+ {
64+ name: "sh exports and unsets",
65+ shell: "sh",
66+ encoded: encoded,
67+ want: "export ODD='it'\\''s \\ $HOME'\nunset SSH_AUTH_SOCK\nexport WAYLAND_DISPLAY='wayland-1'\n",
68+ },
69+ {name: "empty object", shell: "sh", encoded: `{}`, want: ""},
70+ {name: "injected name", shell: "sh", encoded: `{"BAD;rm":"x"}`, wantErr: "invalid session environment name"},
71+ {name: "leading digit", shell: "sh", encoded: `{"1X":"x"}`, wantErr: "invalid session environment name"},
72+ {name: "not json", shell: "sh", encoded: `not json`, wantErr: "decoding session environment"},
73+ }
74+ for _, tt := range tests {
75+ t.Run(tt.name, func(t *testing.T) {
76+ var out bytes.Buffer
77+ err := writeShellEnvironment(&out, tt.shell, tt.encoded)
78+ if tt.wantErr != "" {
79+ require.ErrorContains(t, err, tt.wantErr)
80+ require.Empty(t, out.String())
81+ return
82+ }
83+ require.NoError(t, err)
84+ require.Equal(t, tt.want, out.String())
85+ })
86+ }
87+}
88+
89+func TestRunEnvWithDeps(t *testing.T) {
90+ const reply = `{"SSH_AUTH_SOCK":null,"WAYLAND_DISPLAY":"wayland-1"}` + "\n"
91+ tests := []struct {
92+ name string
93+ invocation envInvocation
94+ vev string
95+ result protocol.CommandResult
96+ want string
97+ wantErr string
98+ wantCode int
99+ wantRequest protocol.CommandRequest
100+ }{
101+ {
102+ name: "inside a pane follows the pane session",
103+ invocation: envInvocation{shell: "fish"},
104+ vev: "session=work,tab=t_1,pane=p_1",
105+ result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: reply},
106+ want: "set -e -g SSH_AUTH_SOCK\nset -gx WAYLAND_DISPLAY 'wayland-1'\n",
107+ wantRequest: protocol.CommandRequest{Slug: "env", JSON: true, TargetSession: "work", TargetTab: "t_1", TargetPane: "p_1"},
108+ },
109+ {
110+ name: "explicit session outside a pane",
111+ invocation: envInvocation{shell: "sh", session: "work"},
112+ result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: reply},
113+ want: "unset SSH_AUTH_SOCK\nexport WAYLAND_DISPLAY='wayland-1'\n",
114+ wantRequest: protocol.CommandRequest{Slug: "env", JSON: true, TargetSession: "work"},
115+ },
116+ {
117+ name: "explicit session inside a pane targets that session only",
118+ invocation: envInvocation{shell: "sh", session: "other"},
119+ vev: "session=work,tab=t_1,pane=p_1",
120+ result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: reply},
121+ want: "unset SSH_AUTH_SOCK\nexport WAYLAND_DISPLAY='wayland-1'\n",
122+ wantRequest: protocol.CommandRequest{Slug: "env", JSON: true, TargetSession: "other"},
123+ },
124+ {
125+ name: "invalid daemon output prints no shell code",
126+ invocation: envInvocation{shell: "sh", session: "work"},
127+ result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: `{"BAD;rm":"x"}`},
128+ wantErr: "invalid session environment name",
129+ wantCode: 1,
130+ },
131+ {
132+ name: "outside a pane without a session",
133+ invocation: envInvocation{shell: "fish"},
134+ wantErr: "inside a vev pane",
135+ wantCode: 2,
136+ },
137+ {
138+ name: "daemon failure prints no shell code",
139+ invocation: envInvocation{shell: "fish", session: "gone"},
140+ result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no such session: gone"},
141+ wantErr: "no such session: gone",
142+ wantCode: 1,
143+ },
144+ {
145+ name: "help",
146+ invocation: envInvocation{help: true},
147+ want: envHelp + "\n",
148+ },
149+ }
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ stream := newSeamBrokerStream(tt.result)
153+ service := newSeamBrokerService(stream)
154+ var out bytes.Buffer
155+ err := runEnvWithDeps(context.Background(), tt.invocation, cmdDeps{
156+ stdout: &out,
157+ getenv: func(string) string { return tt.vev },
158+ connect: func(context.Context) (ports.BrokerService, error) { return service, nil },
159+ dial: func(context.Context, string) (wire.Transport, error) {
160+ return nil, errors.New("unexpected dial")
161+ },
162+ })
163+ if tt.wantErr != "" {
164+ require.ErrorContains(t, err, tt.wantErr)
165+ require.Equal(t, tt.wantCode, exitCode(err))
166+ require.Empty(t, out.String())
167+ return
168+ }
169+ require.NoError(t, err)
170+ require.Equal(t, tt.want, out.String())
171+ if tt.wantRequest.Slug == "" {
172+ require.Zero(t, stream.sentCount())
173+ return
174+ }
175+ stream.mu.Lock()
176+ require.Len(t, stream.sent, 1)
177+ request, ok := stream.sent[0].(protocol.CommandRequest)
178+ stream.mu.Unlock()
179+ require.True(t, ok)
180+ require.Equal(t, tt.wantRequest.Slug, request.Slug)
181+ require.Equal(t, tt.wantRequest.JSON, request.JSON)
182+ require.Equal(t, tt.wantRequest.TargetSession, request.TargetSession)
183+ require.Equal(t, tt.wantRequest.TargetTab, request.TargetTab)
184+ require.Equal(t, tt.wantRequest.TargetPane, request.TargetPane)
185+ })
186+ }
187+}
@@ -0,0 +1,187 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "testing"
8+
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol"
11+ "github.com/bnema/vev/internal/protocol/wire"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestParseEnvArgs(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ args []string
19+ want envInvocation
20+ wantErr string
21+ }{
22+ {name: "fish", args: []string{"fish"}, want: envInvocation{shell: "fish"}},
23+ {name: "sh with session", args: []string{"sh", "-s", "work"}, want: envInvocation{shell: "sh", session: "work"}},
24+ {name: "session before shell", args: []string{"-s", "work", "fish"}, want: envInvocation{shell: "fish", session: "work"}},
25+ {name: "help without shell", args: []string{"--help"}, want: envInvocation{help: true}},
26+ {name: "missing shell", wantErr: "requires a shell"},
27+ {name: "two shells", args: []string{"fish", "sh"}, wantErr: "one shell"},
28+ {name: "same shell twice", args: []string{"fish", "fish"}, wantErr: "one shell"},
29+ {name: "unsupported shell", args: []string{"nu"}, wantErr: `unsupported shell "nu"`},
30+ {name: "json belongs to cmd env", args: []string{"--json"}, wantErr: `unknown flag "--json"`},
31+ {name: "old shell flag", args: []string{"--fish"}, wantErr: `unknown flag "--fish"`},
32+ {name: "missing session", args: []string{"fish", "-s"}, wantErr: "requires a session name"},
33+ }
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ got, err := parseEnvArgs(tt.args)
37+ if tt.wantErr != "" {
38+ require.ErrorContains(t, err, tt.wantErr)
39+ require.Equal(t, 2, exitCode(err))
40+ return
41+ }
42+ require.NoError(t, err)
43+ require.Equal(t, tt.want, got)
44+ })
45+ }
46+}
47+
48+func TestWriteShellEnvironment(t *testing.T) {
49+ encoded := `{"WAYLAND_DISPLAY":"wayland-1","SSH_AUTH_SOCK":null,"ODD":"it's \\ $HOME"}`
50+ tests := []struct {
51+ name string
52+ shell string
53+ encoded string
54+ want string
55+ wantErr string
56+ }{
57+ {
58+ name: "fish sets and erases",
59+ shell: "fish",
60+ encoded: encoded,
61+ want: "set -gx ODD 'it\\'s \\\\ $HOME'\nset -e -g SSH_AUTH_SOCK\nset -gx WAYLAND_DISPLAY 'wayland-1'\n",
62+ },
63+ {
64+ name: "sh exports and unsets",
65+ shell: "sh",
66+ encoded: encoded,
67+ want: "export ODD='it'\\''s \\ $HOME'\nunset SSH_AUTH_SOCK\nexport WAYLAND_DISPLAY='wayland-1'\n",
68+ },
69+ {name: "empty object", shell: "sh", encoded: `{}`, want: ""},
70+ {name: "injected name", shell: "sh", encoded: `{"BAD;rm":"x"}`, wantErr: "invalid session environment name"},
71+ {name: "leading digit", shell: "sh", encoded: `{"1X":"x"}`, wantErr: "invalid session environment name"},
72+ {name: "not json", shell: "sh", encoded: `not json`, wantErr: "decoding session environment"},
73+ }
74+ for _, tt := range tests {
75+ t.Run(tt.name, func(t *testing.T) {
76+ var out bytes.Buffer
77+ err := writeShellEnvironment(&out, tt.shell, tt.encoded)
78+ if tt.wantErr != "" {
79+ require.ErrorContains(t, err, tt.wantErr)
80+ require.Empty(t, out.String())
81+ return
82+ }
83+ require.NoError(t, err)
84+ require.Equal(t, tt.want, out.String())
85+ })
86+ }
87+}
88+
89+func TestRunEnvWithDeps(t *testing.T) {
90+ const reply = `{"SSH_AUTH_SOCK":null,"WAYLAND_DISPLAY":"wayland-1"}` + "\n"
91+ tests := []struct {
92+ name string
93+ invocation envInvocation
94+ vev string
95+ result protocol.CommandResult
96+ want string
97+ wantErr string
98+ wantCode int
99+ wantRequest protocol.CommandRequest
100+ }{
101+ {
102+ name: "inside a pane follows the pane session",
103+ invocation: envInvocation{shell: "fish"},
104+ vev: "session=work,tab=t_1,pane=p_1",
105+ result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: reply},
106+ want: "set -e -g SSH_AUTH_SOCK\nset -gx WAYLAND_DISPLAY 'wayland-1'\n",
107+ wantRequest: protocol.CommandRequest{Slug: "env", JSON: true, TargetSession: "work", TargetTab: "t_1", TargetPane: "p_1"},
108+ },
109+ {
110+ name: "explicit session outside a pane",
111+ invocation: envInvocation{shell: "sh", session: "work"},
112+ result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: reply},
113+ want: "unset SSH_AUTH_SOCK\nexport WAYLAND_DISPLAY='wayland-1'\n",
114+ wantRequest: protocol.CommandRequest{Slug: "env", JSON: true, TargetSession: "work"},
115+ },
116+ {
117+ name: "explicit session inside a pane targets that session only",
118+ invocation: envInvocation{shell: "sh", session: "other"},
119+ vev: "session=work,tab=t_1,pane=p_1",
120+ result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: reply},
121+ want: "unset SSH_AUTH_SOCK\nexport WAYLAND_DISPLAY='wayland-1'\n",
122+ wantRequest: protocol.CommandRequest{Slug: "env", JSON: true, TargetSession: "other"},
123+ },
124+ {
125+ name: "invalid daemon output prints no shell code",
126+ invocation: envInvocation{shell: "sh", session: "work"},
127+ result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: `{"BAD;rm":"x"}`},
128+ wantErr: "invalid session environment name",
129+ wantCode: 1,
130+ },
131+ {
132+ name: "outside a pane without a session",
133+ invocation: envInvocation{shell: "fish"},
134+ wantErr: "inside a vev pane",
135+ wantCode: 2,
136+ },
137+ {
138+ name: "daemon failure prints no shell code",
139+ invocation: envInvocation{shell: "fish", session: "gone"},
140+ result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no such session: gone"},
141+ wantErr: "no such session: gone",
142+ wantCode: 1,
143+ },
144+ {
145+ name: "help",
146+ invocation: envInvocation{help: true},
147+ want: envHelp + "\n",
148+ },
149+ }
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ stream := newSeamBrokerStream(tt.result)
153+ service := newSeamBrokerService(stream)
154+ var out bytes.Buffer
155+ err := runEnvWithDeps(context.Background(), tt.invocation, cmdDeps{
156+ stdout: &out,
157+ getenv: func(string) string { return tt.vev },
158+ connect: func(context.Context) (ports.BrokerService, error) { return service, nil },
159+ dial: func(context.Context, string) (wire.Transport, error) {
160+ return nil, errors.New("unexpected dial")
161+ },
162+ })
163+ if tt.wantErr != "" {
164+ require.ErrorContains(t, err, tt.wantErr)
165+ require.Equal(t, tt.wantCode, exitCode(err))
166+ require.Empty(t, out.String())
167+ return
168+ }
169+ require.NoError(t, err)
170+ require.Equal(t, tt.want, out.String())
171+ if tt.wantRequest.Slug == "" {
172+ require.Zero(t, stream.sentCount())
173+ return
174+ }
175+ stream.mu.Lock()
176+ require.Len(t, stream.sent, 1)
177+ request, ok := stream.sent[0].(protocol.CommandRequest)
178+ stream.mu.Unlock()
179+ require.True(t, ok)
180+ require.Equal(t, tt.wantRequest.Slug, request.Slug)
181+ require.Equal(t, tt.wantRequest.JSON, request.JSON)
182+ require.Equal(t, tt.wantRequest.TargetSession, request.TargetSession)
183+ require.Equal(t, tt.wantRequest.TargetTab, request.TargetTab)
184+ require.Equal(t, tt.wantRequest.TargetPane, request.TargetPane)
185+ })
186+ }
187+}
M internal/app/run.go +11 -0
@@ -58,6 +58,7 @@ const (
5858 kindHost
5959 kindKill
6060 kindCmd
61+ kindEnv
6162 kindDaemon
6263 kindDaemonLauncher
6364 kindUIDriver
@@ -90,6 +91,7 @@ type command struct {
9091 killAll bool
9192 killSessions bool
9293 cmd cmdInvocation
94+ env envInvocation
9395 brokerServe brokerServeOptions
9496 brokerMux brokerMuxOptions
9597 brokerReady brokerReadyOptions
@@ -129,6 +131,7 @@ usage:
129131 vev kill --all stop everything: every vev window, the broker, and the
130132 daemon (named sessions come back on the next start)
131133 vev cmd <command> run a control command (vev cmd --help)
134+ vev env <fish|sh> print shell code that refreshes desktop variables
132135 vev --ui-observe expose passive observation for this interactive client
133136 (optional: --ui-socket PATH)
134137 vev --ui-control expose observation and input control for this client
@@ -308,6 +311,12 @@ parsedUIFlags:
308311 return parseListArgs(args[1:])
309312 case "host":
310313 return parseHostArgs(args[1:])
314+ case "env":
315+ invocation, err := parseEnvArgs(args[1:])
316+ if err != nil {
317+ return command{}, err
318+ }
319+ return command{kind: kindEnv, env: invocation}, nil
311320 case "cmd":
312321 invocation, err := parseCmdArgs(args[1:])
313322 if err != nil {
@@ -395,6 +404,8 @@ func dispatch(ctx context.Context, cmd command) error {
395404 return runKill(ctx, cmd.name, cmd.killSessions)
396405 case kindCmd:
397406 return runCmd(ctx, cmd.cmd)
407+ case kindEnv:
408+ return runEnv(ctx, cmd.env)
398409 case kindAttach:
399410 return runAttachWithOptions(ctx, cmd.intent, cmd.name, cmd.remoteTarget, interactiveUIOptions{observe: cmd.uiObserve, control: cmd.uiControl, socket: cmd.uiSocket})
400411 default:
@@ -58,6 +58,7 @@ const (
58 kindHost58 kindHost
59 kindKill59 kindKill
60 kindCmd60 kindCmd
61+ kindEnv
61 kindDaemon62 kindDaemon
62 kindDaemonLauncher63 kindDaemonLauncher
63 kindUIDriver64 kindUIDriver
@@ -90,6 +91,7 @@ type command struct {
90 killAll bool91 killAll bool
91 killSessions bool92 killSessions bool
92 cmd cmdInvocation93 cmd cmdInvocation
94+ env envInvocation
93 brokerServe brokerServeOptions95 brokerServe brokerServeOptions
94 brokerMux brokerMuxOptions96 brokerMux brokerMuxOptions
95 brokerReady brokerReadyOptions97 brokerReady brokerReadyOptions
@@ -129,6 +131,7 @@ usage:
129 vev kill --all stop everything: every vev window, the broker, and the131 vev kill --all stop everything: every vev window, the broker, and the
130 daemon (named sessions come back on the next start)132 daemon (named sessions come back on the next start)
131 vev cmd <command> run a control command (vev cmd --help)133 vev cmd <command> run a control command (vev cmd --help)
134+ vev env <fish|sh> print shell code that refreshes desktop variables
132 vev --ui-observe expose passive observation for this interactive client135 vev --ui-observe expose passive observation for this interactive client
133 (optional: --ui-socket PATH)136 (optional: --ui-socket PATH)
134 vev --ui-control expose observation and input control for this client137 vev --ui-control expose observation and input control for this client
@@ -308,6 +311,12 @@ parsedUIFlags:
308 return parseListArgs(args[1:])311 return parseListArgs(args[1:])
309 case "host":312 case "host":
310 return parseHostArgs(args[1:])313 return parseHostArgs(args[1:])
314+ case "env":
315+ invocation, err := parseEnvArgs(args[1:])
316+ if err != nil {
317+ return command{}, err
318+ }
319+ return command{kind: kindEnv, env: invocation}, nil
311 case "cmd":320 case "cmd":
312 invocation, err := parseCmdArgs(args[1:])321 invocation, err := parseCmdArgs(args[1:])
313 if err != nil {322 if err != nil {
@@ -395,6 +404,8 @@ func dispatch(ctx context.Context, cmd command) error {
395 return runKill(ctx, cmd.name, cmd.killSessions)404 return runKill(ctx, cmd.name, cmd.killSessions)
396 case kindCmd:405 case kindCmd:
397 return runCmd(ctx, cmd.cmd)406 return runCmd(ctx, cmd.cmd)
407+ case kindEnv:
408+ return runEnv(ctx, cmd.env)
398 case kindAttach:409 case kindAttach:
399 return runAttachWithOptions(ctx, cmd.intent, cmd.name, cmd.remoteTarget, interactiveUIOptions{observe: cmd.uiObserve, control: cmd.uiControl, socket: cmd.uiSocket})410 return runAttachWithOptions(ctx, cmd.intent, cmd.name, cmd.remoteTarget, interactiveUIOptions{observe: cmd.uiObserve, control: cmd.uiControl, socket: cmd.uiSocket})
400 default:411 default:
M internal/usecase/command/command.go +1 -0
@@ -160,6 +160,7 @@ type ControlContext interface {
160160 ListSessions(json bool) (string, error)
161161 ListTabs(json bool) (string, error)
162162 ListPanes(json bool) (string, error)
163+ SessionEnvironment(json bool) (string, error)
163164 RemoteCatalog(json bool) (string, error)
164165 }
165166
@@ -160,6 +160,7 @@ type ControlContext interface {
160 ListSessions(json bool) (string, error)160 ListSessions(json bool) (string, error)
161 ListTabs(json bool) (string, error)161 ListTabs(json bool) (string, error)
162 ListPanes(json bool) (string, error)162 ListPanes(json bool) (string, error)
163+ SessionEnvironment(json bool) (string, error)
163 RemoteCatalog(json bool) (string, error)164 RemoteCatalog(json bool) (string, error)
164 }165 }
165 166
M internal/usecase/command/registry.go +1 -0
@@ -52,6 +52,7 @@ func Registry() []Command {
5252 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),
5353 listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),
5454 listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),
55+ listCommand("env", "Session environment", "Print the session's desktop and login variables (JSON null means unset)", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.SessionEnvironment(json) }),
5556 remoteCatalogCommand(),
5657 sessionRecoveryCommand(),
5758 }
@@ -52,6 +52,7 @@ func Registry() []Command {
52 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),52 listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }),
53 listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),53 listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }),
54 listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),54 listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }),
55+ listCommand("env", "Session environment", "Print the session's desktop and login variables (JSON null means unset)", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.SessionEnvironment(json) }),
55 remoteCatalogCommand(),56 remoteCatalogCommand(),
56 sessionRecoveryCommand(),57 sessionRecoveryCommand(),
57 }58 }
M internal/usecase/command/registry_test.go +8 -2
@@ -145,7 +145,7 @@ func TestRegistryControlMetadata(t *testing.T) {
145145 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
146146 "move-pane": TargetPane, "move-tab": TargetTab,
147147 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
148- "remote-catalog": TargetNone,
148+ "remote-catalog": TargetNone, "env": TargetSession,
149149 }
150150 notScriptable := []string{
151151 "session-picker", "notifications", "visual-mode", "yank-last-notification",
@@ -174,7 +174,7 @@ func TestPaletteRegistryHidesAPIOnly(t *testing.T) {
174174 if !cmd.PaletteVisible || cmd.Run == nil {
175175 t.Errorf("palette command is not executable and visible: %#v", cmd)
176176 }
177- if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" || cmd.Slug == "remote-catalog" {
177+ if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" || cmd.Slug == "remote-catalog" || cmd.Slug == "env" {
178178 t.Errorf("API-only command %q is visible", cmd.Slug)
179179 }
180180 }
@@ -275,6 +275,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
275275 {name: "toast rejects malformed flags", slug: "toast", args: []string{"-l", "warn"}, wantErr: ErrInvalidArguments},
276276 {name: "query delegates JSON option", slug: "list-panes", opts: ControlOptions{JSON: true}, wantCall: "list-panes:true", wantOutput: "panes"},
277277 {name: "query rejects args", slug: "list-panes", args: []string{"extra"}, wantErr: ErrInvalidArguments},
278+ {name: "env delegates JSON option", slug: "env", opts: ControlOptions{JSON: true}, wantCall: "env:true", wantOutput: "env"},
279+ {name: "env rejects args", slug: "env", args: []string{"extra"}, wantErr: ErrInvalidArguments},
278280 }
279281 for _, tt := range tests {
280282 t.Run(tt.name, func(t *testing.T) {
@@ -348,6 +350,10 @@ func (s *controlSpy) ListPanes(json bool) (string, error) {
348350 _ = s.record("list-panes:" + strconv.FormatBool(json))
349351 return "panes", nil
350352 }
353+func (s *controlSpy) SessionEnvironment(json bool) (string, error) {
354+ _ = s.record("env:" + strconv.FormatBool(json))
355+ return "env", nil
356+}
351357 func (s *controlSpy) RemoteCatalog(json bool) (string, error) {
352358 _ = s.record("remote-catalog:" + strconv.FormatBool(json))
353359 return s.catalogOutput, nil
@@ -145,7 +145,7 @@ func TestRegistryControlMetadata(t *testing.T) {
145 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,145 "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab,
146 "move-pane": TargetPane, "move-tab": TargetTab,146 "move-pane": TargetPane, "move-tab": TargetTab,
147 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,147 "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab,
148- "remote-catalog": TargetNone,148+ "remote-catalog": TargetNone, "env": TargetSession,
149 }149 }
150 notScriptable := []string{150 notScriptable := []string{
151 "session-picker", "notifications", "visual-mode", "yank-last-notification",151 "session-picker", "notifications", "visual-mode", "yank-last-notification",
@@ -174,7 +174,7 @@ func TestPaletteRegistryHidesAPIOnly(t *testing.T) {
174 if !cmd.PaletteVisible || cmd.Run == nil {174 if !cmd.PaletteVisible || cmd.Run == nil {
175 t.Errorf("palette command is not executable and visible: %#v", cmd)175 t.Errorf("palette command is not executable and visible: %#v", cmd)
176 }176 }
177- if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" || cmd.Slug == "remote-catalog" {177+ if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" || cmd.Slug == "remote-catalog" || cmd.Slug == "env" {
178 t.Errorf("API-only command %q is visible", cmd.Slug)178 t.Errorf("API-only command %q is visible", cmd.Slug)
179 }179 }
180 }180 }
@@ -275,6 +275,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
275 {name: "toast rejects malformed flags", slug: "toast", args: []string{"-l", "warn"}, wantErr: ErrInvalidArguments},275 {name: "toast rejects malformed flags", slug: "toast", args: []string{"-l", "warn"}, wantErr: ErrInvalidArguments},
276 {name: "query delegates JSON option", slug: "list-panes", opts: ControlOptions{JSON: true}, wantCall: "list-panes:true", wantOutput: "panes"},276 {name: "query delegates JSON option", slug: "list-panes", opts: ControlOptions{JSON: true}, wantCall: "list-panes:true", wantOutput: "panes"},
277 {name: "query rejects args", slug: "list-panes", args: []string{"extra"}, wantErr: ErrInvalidArguments},277 {name: "query rejects args", slug: "list-panes", args: []string{"extra"}, wantErr: ErrInvalidArguments},
278+ {name: "env delegates JSON option", slug: "env", opts: ControlOptions{JSON: true}, wantCall: "env:true", wantOutput: "env"},
279+ {name: "env rejects args", slug: "env", args: []string{"extra"}, wantErr: ErrInvalidArguments},
278 }280 }
279 for _, tt := range tests {281 for _, tt := range tests {
280 t.Run(tt.name, func(t *testing.T) {282 t.Run(tt.name, func(t *testing.T) {
@@ -348,6 +350,10 @@ func (s *controlSpy) ListPanes(json bool) (string, error) {
348 _ = s.record("list-panes:" + strconv.FormatBool(json))350 _ = s.record("list-panes:" + strconv.FormatBool(json))
349 return "panes", nil351 return "panes", nil
350 }352 }
353+func (s *controlSpy) SessionEnvironment(json bool) (string, error) {
354+ _ = s.record("env:" + strconv.FormatBool(json))
355+ return "env", nil
356+}
351 func (s *controlSpy) RemoteCatalog(json bool) (string, error) {357 func (s *controlSpy) RemoteCatalog(json bool) (string, error) {
352 _ = s.record("remote-catalog:" + strconv.FormatBool(json))358 _ = s.record("remote-catalog:" + strconv.FormatBool(json))
353 return s.catalogOutput, nil359 return s.catalogOutput, nil
M internal/usecase/daemon/attachment_transition.go +9 -10
@@ -20,16 +20,15 @@ type attachmentLifecycleFence struct {
2020 }
2121
2222 type attachmentTransitionRequest struct {
23- source *session
24- target *session
25- next *attachedClient
26- expectedTransport transportSnapshot
27- sourceCapability *attachmentCapability
28- sourceEffect *attachmentEffect
29- action string
30- activateTargetTab bool
31- targetTabIndex int
32- copySourceEnvironment bool
23+ source *session
24+ target *session
25+ next *attachedClient
26+ expectedTransport transportSnapshot
27+ sourceCapability *attachmentCapability
28+ sourceEffect *attachmentEffect
29+ action string
30+ activateTargetTab bool
31+ targetTabIndex int
3332 // preserveAttachment commits Attachment-local navigation state without
3433 // changing Session membership. The initiating capability remains the exact
3534 // authority for this mutation.
@@ -20,16 +20,15 @@ type attachmentLifecycleFence struct {
20 }20 }
21 21
22 type attachmentTransitionRequest struct {22 type attachmentTransitionRequest struct {
23- source *session23+ source *session
24- target *session24+ target *session
25- next *attachedClient25+ next *attachedClient
26- expectedTransport transportSnapshot26+ expectedTransport transportSnapshot
27- sourceCapability *attachmentCapability27+ sourceCapability *attachmentCapability
28- sourceEffect *attachmentEffect28+ sourceEffect *attachmentEffect
29- action string29+ action string
30- activateTargetTab bool30+ activateTargetTab bool
31- targetTabIndex int31+ targetTabIndex int
32- copySourceEnvironment bool
33 // preserveAttachment commits Attachment-local navigation state without32 // preserveAttachment commits Attachment-local navigation state without
34 // changing Session membership. The initiating capability remains the exact33 // changing Session membership. The initiating capability remains the exact
35 // authority for this mutation.34 // authority for this mutation.
M internal/usecase/daemon/attachment_transition_publish.go +8 -2
@@ -173,8 +173,14 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool
173173 if req.activateTargetTab && req.target != nil {
174174 req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
175175 }
176- if req.copySourceEnvironment && publication.source != nil && req.target != nil {
177- req.target.env = copyEnvironment(publication.source.env)
176+ // Every attach, resume, and session handoff refreshes the target from the
177+ // attachment's own client environment, inside the publication so a failed
178+ // transition changes nothing. A daemon-owned (remote) attachment never
179+ // changes it; a preserved attachment stays in its session.
180+ if !req.preserveAttachment && req.target != nil && req.next != nil {
181+ if env, ok := req.next.clientEnvironment(); ok {
182+ req.target.adoptClientEnvironmentLocked(env)
183+ }
178184 }
179185 return req.preserveAttachment
180186 }
@@ -173,8 +173,14 @@ func (d *Daemon) applyTargetStateLocked(publication *attachmentPublication) bool
173 if req.activateTargetTab && req.target != nil {173 if req.activateTargetTab && req.target != nil {
174 req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex)174 req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex)
175 }175 }
176- if req.copySourceEnvironment && publication.source != nil && req.target != nil {176+ // Every attach, resume, and session handoff refreshes the target from the
177- req.target.env = copyEnvironment(publication.source.env)177+ // attachment's own client environment, inside the publication so a failed
178+ // transition changes nothing. A daemon-owned (remote) attachment never
179+ // changes it; a preserved attachment stays in its session.
180+ if !req.preserveAttachment && req.target != nil && req.next != nil {
181+ if env, ok := req.next.clientEnvironment(); ok {
182+ req.target.adoptClientEnvironmentLocked(env)
183+ }
178 }184 }
179 return req.preserveAttachment185 return req.preserveAttachment
180 }186 }
M internal/usecase/daemon/bar_refresh.go +3 -3
@@ -118,9 +118,9 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
118118 }
119119 sess.mu.Lock()
120120 ctx.Session = sess.name
121- // No copy needed: writers always replace sess.env with a whole fresh
122- // slice under sess.mu (daemon.go, resume.go), and nothing mutates the
123- // backing array in place, so capturing the header here is safe.
121+ // No copy needed: every writer replaces sess.env with a whole fresh slice
122+ // under sess.mu and nothing mutates the backing array in place, so
123+ // capturing the header here is safe.
124124 env := sess.env
125125 attachments := sess.snapshotAttachmentsLocked()
126126 ctx.PaneCWD = sess.cwd
@@ -118,9 +118,9 @@ func (d *Daemon) collectBarScriptContext(sess *session, anchor string) (barScrip
118 }118 }
119 sess.mu.Lock()119 sess.mu.Lock()
120 ctx.Session = sess.name120 ctx.Session = sess.name
121- // No copy needed: writers always replace sess.env with a whole fresh121+ // No copy needed: every writer replaces sess.env with a whole fresh slice
122- // slice under sess.mu (daemon.go, resume.go), and nothing mutates the122+ // under sess.mu and nothing mutates the backing array in place, so
123- // backing array in place, so capturing the header here is safe.123+ // capturing the header here is safe.
124 env := sess.env124 env := sess.env
125 attachments := sess.snapshotAttachmentsLocked()125 attachments := sess.snapshotAttachmentsLocked()
126 ctx.PaneCWD = sess.cwd126 ctx.PaneCWD = sess.cwd
M internal/usecase/daemon/client.go +8 -0
@@ -76,6 +76,12 @@ type attachedClient struct {
7676 // clientPID is the local client process from Hello, 0 when remote. It is
7777 // atomic because a resume replaces it while bar scripts read it.
7878 clientPID atomic.Uint32
79+ // clientEnv is the immutable client-owned environment from the latest
80+ // Hello, or nil when the daemon owns the environment (remote). A resume
81+ // replaces it, so a remote resume of a local attachment clears it. Session
82+ // handoffs refresh the target from it. Atomic because a resume replaces it
83+ // while handoffs read it.
84+ clientEnv atomic.Pointer[[]string]
7985 // prepareFailureFallback prevents a direct fallback paint from recursively
8086 // reporting the same failed prepare through its notice repaint. It is only
8187 // needed while no render coordinator is installed.
@@ -661,6 +667,7 @@ type attachClientOptions struct {
661667 navigationCapabilities protocol.NavigationCapabilities
662668 terminalFocus domain.TerminalFocus
663669 clientPID uint32
670+ clientEnv []string
664671 }
665672
666673 func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -744,6 +751,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
744751 }
745752 ac.setTerminalFocus(opts.terminalFocus)
746753 ac.clientPID.Store(opts.clientPID)
754+ ac.setClientEnvironment(opts.clientEnv)
747755 output.attachment = ac
748756 ac.initOverlays()
749757 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
@@ -76,6 +76,12 @@ type attachedClient struct {
76 // clientPID is the local client process from Hello, 0 when remote. It is76 // clientPID is the local client process from Hello, 0 when remote. It is
77 // atomic because a resume replaces it while bar scripts read it.77 // atomic because a resume replaces it while bar scripts read it.
78 clientPID atomic.Uint3278 clientPID atomic.Uint32
79+ // clientEnv is the immutable client-owned environment from the latest
80+ // Hello, or nil when the daemon owns the environment (remote). A resume
81+ // replaces it, so a remote resume of a local attachment clears it. Session
82+ // handoffs refresh the target from it. Atomic because a resume replaces it
83+ // while handoffs read it.
84+ clientEnv atomic.Pointer[[]string]
79 // prepareFailureFallback prevents a direct fallback paint from recursively85 // prepareFailureFallback prevents a direct fallback paint from recursively
80 // reporting the same failed prepare through its notice repaint. It is only86 // reporting the same failed prepare through its notice repaint. It is only
81 // needed while no render coordinator is installed.87 // needed while no render coordinator is installed.
@@ -661,6 +667,7 @@ type attachClientOptions struct {
661 navigationCapabilities protocol.NavigationCapabilities667 navigationCapabilities protocol.NavigationCapabilities
662 terminalFocus domain.TerminalFocus668 terminalFocus domain.TerminalFocus
663 clientPID uint32669 clientPID uint32
670+ clientEnv []string
664 }671 }
665 672
666 func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {673 func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -744,6 +751,7 @@ func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConne
744 }751 }
745 ac.setTerminalFocus(opts.terminalFocus)752 ac.setTerminalFocus(opts.terminalFocus)
746 ac.clientPID.Store(opts.clientPID)753 ac.clientPID.Store(opts.clientPID)
754+ ac.setClientEnvironment(opts.clientEnv)
747 output.attachment = ac755 output.attachment = ac
748 ac.initOverlays()756 ac.initOverlays()
749 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)757 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
M internal/usecase/daemon/control.go +15 -2
@@ -542,7 +542,8 @@ func (e controlExec) CreateSessionNamed(name string) error {
542542 return command.ErrInvalidArguments
543543 }
544544 e.sess.mu.Lock()
545- cwd, env := e.sess.cwd, copyEnvironment(e.sess.env)
545+ cwd := e.sess.cwd
546+ env, provisional := e.sess.environmentSeedLocked()
546547 e.sess.mu.Unlock()
547548 geometry := domain.Geometry{Size: e.sess.fullViewportSize()}
548549 if source, ok := e.sess.geometry.sourceSnapshot(e.sess); ok {
@@ -559,7 +560,10 @@ func (e controlExec) CreateSessionNamed(name string) error {
559560 if e.d.nameLiveOrStoppedLocked(name) {
560561 return errSessionNameInUse
561562 }
562- _, err := e.d.createSessionLocked(name, false, cwd, geometry, env)
563+ created, err := e.d.createSessionLocked(name, false, cwd, geometry, env)
564+ if err == nil && provisional {
565+ markEnvironmentProvisional(created)
566+ }
563567 return err
564568 }
565569 func (e controlExec) CloseTab() error {
@@ -906,6 +910,15 @@ func (e controlExec) ListTabs(asJSON bool) (string, error) {
906910 return out.String(), nil
907911 }
908912
913+// SessionEnvironment exports the session-bound variables that future PTY
914+// children of the target session receive, so running shells can catch up.
915+func (e controlExec) SessionEnvironment(asJSON bool) (string, error) {
916+ e.sess.mu.Lock()
917+ env := e.sess.env
918+ e.sess.mu.Unlock()
919+ return sessionEnvironmentExport(env, asJSON)
920+}
921+
909922 func (e controlExec) ListPanes(asJSON bool) (string, error) {
910923 type row struct {
911924 ID string `json:"id"`
@@ -542,7 +542,8 @@ func (e controlExec) CreateSessionNamed(name string) error {
542 return command.ErrInvalidArguments542 return command.ErrInvalidArguments
543 }543 }
544 e.sess.mu.Lock()544 e.sess.mu.Lock()
545- cwd, env := e.sess.cwd, copyEnvironment(e.sess.env)545+ cwd := e.sess.cwd
546+ env, provisional := e.sess.environmentSeedLocked()
546 e.sess.mu.Unlock()547 e.sess.mu.Unlock()
547 geometry := domain.Geometry{Size: e.sess.fullViewportSize()}548 geometry := domain.Geometry{Size: e.sess.fullViewportSize()}
548 if source, ok := e.sess.geometry.sourceSnapshot(e.sess); ok {549 if source, ok := e.sess.geometry.sourceSnapshot(e.sess); ok {
@@ -559,7 +560,10 @@ func (e controlExec) CreateSessionNamed(name string) error {
559 if e.d.nameLiveOrStoppedLocked(name) {560 if e.d.nameLiveOrStoppedLocked(name) {
560 return errSessionNameInUse561 return errSessionNameInUse
561 }562 }
562- _, err := e.d.createSessionLocked(name, false, cwd, geometry, env)563+ created, err := e.d.createSessionLocked(name, false, cwd, geometry, env)
564+ if err == nil && provisional {
565+ markEnvironmentProvisional(created)
566+ }
563 return err567 return err
564 }568 }
565 func (e controlExec) CloseTab() error {569 func (e controlExec) CloseTab() error {
@@ -906,6 +910,15 @@ func (e controlExec) ListTabs(asJSON bool) (string, error) {
906 return out.String(), nil910 return out.String(), nil
907 }911 }
908 912
913+// SessionEnvironment exports the session-bound variables that future PTY
914+// children of the target session receive, so running shells can catch up.
915+func (e controlExec) SessionEnvironment(asJSON bool) (string, error) {
916+ e.sess.mu.Lock()
917+ env := e.sess.env
918+ e.sess.mu.Unlock()
919+ return sessionEnvironmentExport(env, asJSON)
920+}
921+
909 func (e controlExec) ListPanes(asJSON bool) (string, error) {922 func (e controlExec) ListPanes(asJSON bool) (string, error) {
910 type row struct {923 type row struct {
911 ID string `json:"id"`924 ID string `json:"id"`
M internal/usecase/daemon/control_test.go +32 -0
@@ -736,6 +736,38 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
736736 require.Equal(t, true, decoded[0]["focused"])
737737 }
738738
739+func TestHandleCommandEnvExportsTargetSessionEnvironment(t *testing.T) {
740+ tests := []struct {
741+ name string
742+ request protocol.CommandRequest
743+ want string
744+ }{
745+ {
746+ name: "text by session name",
747+ request: protocol.CommandRequest{Slug: "env", TargetSession: "work"},
748+ want: "WAYLAND_DISPLAY=wayland-1\n",
749+ },
750+ {
751+ name: "json from inside a pane",
752+ request: protocol.CommandRequest{Slug: "env", TargetTab: "t_work", TargetPane: "p_work", JSON: true},
753+ want: `{"DISPLAY":null,"SSH_AGENT_PID":null,"SSH_AUTH_SOCK":null,"SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"WAYLAND_DISPLAY":"wayland-1","XAUTHORITY":null,"XDG_CURRENT_DESKTOP":null,"XDG_SESSION_CLASS":null,"XDG_SESSION_DESKTOP":null,"XDG_SESSION_ID":null,"XDG_SESSION_TYPE":null}` + "\n",
754+ },
755+ }
756+ for _, tt := range tests {
757+ t.Run(tt.name, func(t *testing.T) {
758+ d := newTestDaemon(t, nil, stubClock{})
759+ sess := addControlSession(d, "work", "t_work", "p_work")
760+ sess.mu.Lock()
761+ sess.env = []string{"SHELL=/bin/sh", "SECRET=hidden", "WAYLAND_DISPLAY=wayland-1"}
762+ sess.mu.Unlock()
763+
764+ result := sendCommand(t, d, tt.request)
765+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
766+ require.Equal(t, tt.want, result.Output)
767+ })
768+ }
769+}
770+
739771 func TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) {
740772 d := newTestDaemon(t, nil, stubClock{})
741773 d.mu.Lock()
@@ -736,6 +736,38 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
736 require.Equal(t, true, decoded[0]["focused"])736 require.Equal(t, true, decoded[0]["focused"])
737 }737 }
738 738
739+func TestHandleCommandEnvExportsTargetSessionEnvironment(t *testing.T) {
740+ tests := []struct {
741+ name string
742+ request protocol.CommandRequest
743+ want string
744+ }{
745+ {
746+ name: "text by session name",
747+ request: protocol.CommandRequest{Slug: "env", TargetSession: "work"},
748+ want: "WAYLAND_DISPLAY=wayland-1\n",
749+ },
750+ {
751+ name: "json from inside a pane",
752+ request: protocol.CommandRequest{Slug: "env", TargetTab: "t_work", TargetPane: "p_work", JSON: true},
753+ want: `{"DISPLAY":null,"SSH_AGENT_PID":null,"SSH_AUTH_SOCK":null,"SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"WAYLAND_DISPLAY":"wayland-1","XAUTHORITY":null,"XDG_CURRENT_DESKTOP":null,"XDG_SESSION_CLASS":null,"XDG_SESSION_DESKTOP":null,"XDG_SESSION_ID":null,"XDG_SESSION_TYPE":null}` + "\n",
754+ },
755+ }
756+ for _, tt := range tests {
757+ t.Run(tt.name, func(t *testing.T) {
758+ d := newTestDaemon(t, nil, stubClock{})
759+ sess := addControlSession(d, "work", "t_work", "p_work")
760+ sess.mu.Lock()
761+ sess.env = []string{"SHELL=/bin/sh", "SECRET=hidden", "WAYLAND_DISPLAY=wayland-1"}
762+ sess.mu.Unlock()
763+
764+ result := sendCommand(t, d, tt.request)
765+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
766+ require.Equal(t, tt.want, result.Output)
767+ })
768+ }
769+}
770+
739 func TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) {771 func TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) {
740 d := newTestDaemon(t, nil, stubClock{})772 d := newTestDaemon(t, nil, stubClock{})
741 d.mu.Lock()773 d.mu.Lock()
M internal/usecase/daemon/daemon.go +8 -10
@@ -1477,15 +1477,6 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
14771477 // otherwise falls back to the session's normal first-tab repair.
14781478 initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
14791479 }
1480- // Session state is the sole source for future PTY children. Update it before
1481- // publishing the attachment; existing PTYs keep their original environment.
1482- // A picker handoff deliberately leaves the daemon-owned environment and CWD
1483- // untouched, even though Hello retains those fields for direct CLI clients.
1484- sess.mu.Lock()
1485- if h.EnvironmentPolicy != protocol.EnvironmentPolicyDaemonOwned {
1486- sess.env = copyEnvironment(h.Env)
1487- }
1488- sess.mu.Unlock()
14891480 terminalCapabilities := terminalcap.Detect(h.Env)
14901481 // Kitty graphics are enabled only by the explicit direct-terminal
14911482 // declaration in Hello. Environment values remain useful for color and
@@ -1503,6 +1494,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
15031494 navigationCapabilities: h.NavigationCapabilities,
15041495 terminalFocus: h.TerminalFocus,
15051496 clientPID: h.ClientPID,
1497+ clientEnv: helloClientEnvironment(h),
15061498 terminalCapabilities: terminalCapabilities,
15071499 capabilitiesSet: true,
15081500 }
@@ -1819,7 +1811,8 @@ func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr port
18191811 }
18201812 cwd := d.dirOrHome(stopped.cwd)
18211813 env := h.Env
1822- if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
1814+ daemonOwned := h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned
1815+ if daemonOwned {
18231816 env = copyEnvironment(d.baseEnv)
18241817 }
18251818 var err error
@@ -1828,6 +1821,11 @@ func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr port
18281821 d.mu.Unlock()
18291822 return nil, nil, err
18301823 }
1824+ if daemonOwned {
1825+ // Like a snapshot restore, the resumed session starts from the
1826+ // daemon's environment until a local client attaches.
1827+ markEnvironmentProvisional(sess)
1828+ }
18311829 created = true
18321830 }
18331831 ac, err := d.finishRouteAttach(sess, tr, sz, h, created, false)
@@ -1477,15 +1477,6 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
1477 // otherwise falls back to the session's normal first-tab repair.1477 // otherwise falls back to the session's normal first-tab repair.
1478 initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)1478 initialTabIndex = preferredTabIndex(sess, h.PreferredTabID)
1479 }1479 }
1480- // Session state is the sole source for future PTY children. Update it before
1481- // publishing the attachment; existing PTYs keep their original environment.
1482- // A picker handoff deliberately leaves the daemon-owned environment and CWD
1483- // untouched, even though Hello retains those fields for direct CLI clients.
1484- sess.mu.Lock()
1485- if h.EnvironmentPolicy != protocol.EnvironmentPolicyDaemonOwned {
1486- sess.env = copyEnvironment(h.Env)
1487- }
1488- sess.mu.Unlock()
1489 terminalCapabilities := terminalcap.Detect(h.Env)1480 terminalCapabilities := terminalcap.Detect(h.Env)
1490 // Kitty graphics are enabled only by the explicit direct-terminal1481 // Kitty graphics are enabled only by the explicit direct-terminal
1491 // declaration in Hello. Environment values remain useful for color and1482 // declaration in Hello. Environment values remain useful for color and
@@ -1503,6 +1494,7 @@ func (d *Daemon) finishAttach(sess *session, tr ports.ServerConnection, sz domai
1503 navigationCapabilities: h.NavigationCapabilities,1494 navigationCapabilities: h.NavigationCapabilities,
1504 terminalFocus: h.TerminalFocus,1495 terminalFocus: h.TerminalFocus,
1505 clientPID: h.ClientPID,1496 clientPID: h.ClientPID,
1497+ clientEnv: helloClientEnvironment(h),
1506 terminalCapabilities: terminalCapabilities,1498 terminalCapabilities: terminalCapabilities,
1507 capabilitiesSet: true,1499 capabilitiesSet: true,
1508 }1500 }
@@ -1819,7 +1811,8 @@ func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr port
1819 }1811 }
1820 cwd := d.dirOrHome(stopped.cwd)1812 cwd := d.dirOrHome(stopped.cwd)
1821 env := h.Env1813 env := h.Env
1822- if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {1814+ daemonOwned := h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned
1815+ if daemonOwned {
1823 env = copyEnvironment(d.baseEnv)1816 env = copyEnvironment(d.baseEnv)
1824 }1817 }
1825 var err error1818 var err error
@@ -1828,6 +1821,11 @@ func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr port
1828 d.mu.Unlock()1821 d.mu.Unlock()
1829 return nil, nil, err1822 return nil, nil, err
1830 }1823 }
1824+ if daemonOwned {
1825+ // Like a snapshot restore, the resumed session starts from the
1826+ // daemon's environment until a local client attaches.
1827+ markEnvironmentProvisional(sess)
1828+ }
1831 created = true1829 created = true
1832 }1830 }
1833 ac, err := d.finishRouteAttach(sess, tr, sz, h, created, false)1831 ac, err := d.finishRouteAttach(sess, tr, sz, h, created, false)
M internal/usecase/daemon/move_picker_test.go +7 -0
@@ -230,6 +230,10 @@ func TestMovePickerMoveCommitsTab(t *testing.T) {
230230 defer releaseAll(releases)
231231 movedTab := source.tabs[0]
232232 movedTab.stableID = "moved-tab"
233+ ac.setClientEnvironment([]string{"WAYLAND_DISPLAY=wayland-mover"})
234+ destination.mu.Lock()
235+ destination.env = []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-old"}
236+ destination.mu.Unlock()
233237
234238 openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "moved-tab", ""))
235239 effect := pickActionEffectForTest(t, source, ac)
@@ -244,6 +248,9 @@ func TestMovePickerMoveCommitsTab(t *testing.T) {
244248 require.Nil(t, source.tabs)
245249 require.Len(t, destination.tabs, 2)
246250 require.Same(t, movedTab, destination.tabs[1])
251+ destination.mu.Lock()
252+ defer destination.mu.Unlock()
253+ require.Equal(t, []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-mover"}, destination.env, "a followed move refreshes the destination from the moving attachment")
247254 }
248255
249256 func TestMovePickerCancelPerformsNoMutation(t *testing.T) {
@@ -230,6 +230,10 @@ func TestMovePickerMoveCommitsTab(t *testing.T) {
230 defer releaseAll(releases)230 defer releaseAll(releases)
231 movedTab := source.tabs[0]231 movedTab := source.tabs[0]
232 movedTab.stableID = "moved-tab"232 movedTab.stableID = "moved-tab"
233+ ac.setClientEnvironment([]string{"WAYLAND_DISPLAY=wayland-mover"})
234+ destination.mu.Lock()
235+ destination.env = []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-old"}
236+ destination.mu.Unlock()
233 237
234 openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "moved-tab", ""))238 openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "moved-tab", ""))
235 effect := pickActionEffectForTest(t, source, ac)239 effect := pickActionEffectForTest(t, source, ac)
@@ -244,6 +248,9 @@ func TestMovePickerMoveCommitsTab(t *testing.T) {
244 require.Nil(t, source.tabs)248 require.Nil(t, source.tabs)
245 require.Len(t, destination.tabs, 2)249 require.Len(t, destination.tabs, 2)
246 require.Same(t, movedTab, destination.tabs[1])250 require.Same(t, movedTab, destination.tabs[1])
251+ destination.mu.Lock()
252+ defer destination.mu.Unlock()
253+ require.Equal(t, []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-mover"}, destination.env, "a followed move refreshes the destination from the moving attachment")
247 }254 }
248 255
249 func TestMovePickerCancelPerformsNoMutation(t *testing.T) {256 func TestMovePickerCancelPerformsNoMutation(t *testing.T) {
M internal/usecase/daemon/picker.go +11 -5
@@ -246,7 +246,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect
246246
247247 expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: action,
248248 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
249- activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
249+ activateTargetTab: true, targetTabIndex: target.TabIndex, ready: true,
250250 })
251251 if err != nil {
252252 // Losing the exact source role is a benign stale action, not a notice for
@@ -544,7 +544,6 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
544544 expectedSourceTab: guard.expectedSource,
545545 activateTargetTab: target.TabIndex >= 0,
546546 targetTabIndex: target.TabIndex,
547- copySourceEnvironment: true,
548547 ready: true,
549548 })
550549 d.mu.Lock()
@@ -573,7 +572,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
573572 transition, err := d.transitionAttachment(attachmentTransitionRequest{
574573 source: from, next: ac,
575574 expectedTransport: sourceCapability.transport, sourceCapability: &sourceCapability, sourceEffect: sourceEffect, action: action,
576- expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
575+ expectedSourceTab: guard.expectedSource, ready: true,
577576 createTargetLocked: func() (*session, error) {
578577 if d.purgeAdmissionClosedLocked() {
579578 return nil, errPurgeAdmissionClosed
@@ -583,9 +582,13 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
583582 return nil, errAttachmentTransition
584583 }
585584 from.mu.Lock()
586- cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)
585+ env, provisional := from.environmentSeedLocked()
587586 from.mu.Unlock()
587+ cwd := d.dirOrHome(current.cwd)
588588 created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames)
589+ if createErr == nil && provisional {
590+ markEnvironmentProvisional(created)
591+ }
589592 targetSess = created
590593 return created, createErr
591594 },
@@ -609,7 +612,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
609612 from.mu.Unlock()
610613 return nil, attachmentTransitionResult{}, false, nil
611614 }
612- env := copyEnvironment(from.env)
615+ env, provisional := from.environmentSeedLocked()
613616 from.mu.Unlock()
614617 if d.purgeAdmissionClosedLocked() {
615618 return nil, attachmentTransitionResult{}, false, errPurgeAdmissionClosed
@@ -624,6 +627,9 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
624627 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
625628 return nil, attachmentTransitionResult{}, false, err
626629 }
630+ if provisional {
631+ markEnvironmentProvisional(targetSess)
632+ }
627633
628634 d.mu.Unlock()
629635 transition, err := d.transitionAttachment(attachmentTransitionRequest{
@@ -246,7 +246,7 @@ func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect
246 246
247 expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: action,247 expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: action,
248 expectedTargetLifecycle: pickerTargetLifecycleFence(target),248 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
249- activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,249+ activateTargetTab: true, targetTabIndex: target.TabIndex, ready: true,
250 })250 })
251 if err != nil {251 if err != nil {
252 // Losing the exact source role is a benign stale action, not a notice for252 // Losing the exact source role is a benign stale action, not a notice for
@@ -544,7 +544,6 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
544 expectedSourceTab: guard.expectedSource,544 expectedSourceTab: guard.expectedSource,
545 activateTargetTab: target.TabIndex >= 0,545 activateTargetTab: target.TabIndex >= 0,
546 targetTabIndex: target.TabIndex,546 targetTabIndex: target.TabIndex,
547- copySourceEnvironment: true,
548 ready: true,547 ready: true,
549 })548 })
550 d.mu.Lock()549 d.mu.Lock()
@@ -573,7 +572,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
573 transition, err := d.transitionAttachment(attachmentTransitionRequest{572 transition, err := d.transitionAttachment(attachmentTransitionRequest{
574 source: from, next: ac,573 source: from, next: ac,
575 expectedTransport: sourceCapability.transport, sourceCapability: &sourceCapability, sourceEffect: sourceEffect, action: action,574 expectedTransport: sourceCapability.transport, sourceCapability: &sourceCapability, sourceEffect: sourceEffect, action: action,
576- expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,575+ expectedSourceTab: guard.expectedSource, ready: true,
577 createTargetLocked: func() (*session, error) {576 createTargetLocked: func() (*session, error) {
578 if d.purgeAdmissionClosedLocked() {577 if d.purgeAdmissionClosedLocked() {
579 return nil, errPurgeAdmissionClosed578 return nil, errPurgeAdmissionClosed
@@ -583,9 +582,13 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
583 return nil, errAttachmentTransition582 return nil, errAttachmentTransition
584 }583 }
585 from.mu.Lock()584 from.mu.Lock()
586- cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env)585+ env, provisional := from.environmentSeedLocked()
587 from.mu.Unlock()586 from.mu.Unlock()
587+ cwd := d.dirOrHome(current.cwd)
588 created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames)588 created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames)
589+ if createErr == nil && provisional {
590+ markEnvironmentProvisional(created)
591+ }
589 targetSess = created592 targetSess = created
590 return created, createErr593 return created, createErr
591 },594 },
@@ -609,7 +612,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
609 from.mu.Unlock()612 from.mu.Unlock()
610 return nil, attachmentTransitionResult{}, false, nil613 return nil, attachmentTransitionResult{}, false, nil
611 }614 }
612- env := copyEnvironment(from.env)615+ env, provisional := from.environmentSeedLocked()
613 from.mu.Unlock()616 from.mu.Unlock()
614 if d.purgeAdmissionClosedLocked() {617 if d.purgeAdmissionClosedLocked() {
615 return nil, attachmentTransitionResult{}, false, errPurgeAdmissionClosed618 return nil, attachmentTransitionResult{}, false, errPurgeAdmissionClosed
@@ -624,6 +627,9 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient,
624 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)627 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
625 return nil, attachmentTransitionResult{}, false, err628 return nil, attachmentTransitionResult{}, false, err
626 }629 }
630+ if provisional {
631+ markEnvironmentProvisional(targetSess)
632+ }
627 633
628 d.mu.Unlock()634 d.mu.Unlock()
629 transition, err := d.transitionAttachment(attachmentTransitionRequest{635 transition, err := d.transitionAttachment(attachmentTransitionRequest{
M internal/usecase/daemon/remote_attach.go +3 -0
@@ -88,6 +88,9 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h protocol.He
8888 }
8989 return nil, nil, err
9090 }
91+ // Like a snapshot restore, the resumed session starts from the daemon's
92+ // environment until a local client attaches.
93+ markEnvironmentProvisional(sess)
9194 ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false)
9295 return sess, ac, err
9396 }
@@ -88,6 +88,9 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h protocol.He
88 }88 }
89 return nil, nil, err89 return nil, nil, err
90 }90 }
91+ // Like a snapshot restore, the resumed session starts from the daemon's
92+ // environment until a local client attaches.
93+ markEnvironmentProvisional(sess)
91 ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false)94 ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false)
92 return sess, ac, err95 return sess, ac, err
93 }96 }
M internal/usecase/daemon/resume.go +1 -7
@@ -626,6 +626,7 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
626626 // The replacement terminal's focus, too, is declared before first paint.
627627 ac.setTerminalFocus(h.TerminalFocus)
628628 ac.clientPID.Store(h.ClientPID)
629+ ac.setClientEnvironment(helloClientEnvironment(h))
629630 geometry := h.Geometry()
630631 if geometry.Size != sz {
631632 geometry = domain.Geometry{Size: sz}
@@ -633,13 +634,6 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
633634 ac.setGeometry(geometry)
634635 ac.resumeToken = d.nextResumeTokenLocked()
635636 ac.parked = false
636- // The resumed session's snapshot is the sole source for future PTY children.
637- // Existing PTYs retain the environment they were started with.
638- sess.mu.Lock()
639- if h.EnvironmentPolicy != protocol.EnvironmentPolicyDaemonOwned {
640- sess.env = copyEnvironment(h.Env)
641- }
642- sess.mu.Unlock()
643637 preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID)
644638 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with
645639 // neither held. Reacquire them before returning to preserve this helper's
@@ -626,6 +626,7 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
626 // The replacement terminal's focus, too, is declared before first paint.626 // The replacement terminal's focus, too, is declared before first paint.
627 ac.setTerminalFocus(h.TerminalFocus)627 ac.setTerminalFocus(h.TerminalFocus)
628 ac.clientPID.Store(h.ClientPID)628 ac.clientPID.Store(h.ClientPID)
629+ ac.setClientEnvironment(helloClientEnvironment(h))
629 geometry := h.Geometry()630 geometry := h.Geometry()
630 if geometry.Size != sz {631 if geometry.Size != sz {
631 geometry = domain.Geometry{Size: sz}632 geometry = domain.Geometry{Size: sz}
@@ -633,13 +634,6 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection,
633 ac.setGeometry(geometry)634 ac.setGeometry(geometry)
634 ac.resumeToken = d.nextResumeTokenLocked()635 ac.resumeToken = d.nextResumeTokenLocked()
635 ac.parked = false636 ac.parked = false
636- // The resumed session's snapshot is the sole source for future PTY children.
637- // Existing PTYs retain the environment they were started with.
638- sess.mu.Lock()
639- if h.EnvironmentPolicy != protocol.EnvironmentPolicyDaemonOwned {
640- sess.env = copyEnvironment(h.Env)
641- }
642- sess.mu.Unlock()
643 preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID)637 preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID)
644 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with638 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with
645 // neither held. Reacquire them before returning to preserve this helper's639 // neither held. Reacquire them before returning to preserve this helper's
M internal/usecase/daemon/resume_test.go +14 -7
@@ -1561,7 +1561,7 @@ func TestSequencedInputDoesNotPrematurelyEcho(t *testing.T) {
15611561 require.Zero(t, ac.echoAck.Load())
15621562 }
15631563
1564-func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
1564+func TestResumeParkedRefreshesSessionBoundFuturePTYEnvironment(t *testing.T) {
15651565 initialPTY, releaseInitial := newBlockingPTY(t)
15661566 futurePTY, releaseFuture := newBlockingPTY(t)
15671567 defer releaseInitial()
@@ -1585,7 +1585,7 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
15851585 d := newTestDaemon(t, factory, stubClock{})
15861586 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
15871587 hello := helloResumeCapable(protocol.IntentNew, "work", 0)
1588- hello.Env = []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old"}
1588+ hello.Env = []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old", "SSH_AUTH_SOCK=/run/old-agent"}
15891589 sess, ac, err := d.route(hello, tr)
15901590 require.NoError(t, err)
15911591 token := ac.resumeToken
@@ -1593,7 +1593,7 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
15931593 require.True(t, d.parkAttachment(sess, ac))
15941594
15951595 resumeHello := helloResumeCapable(protocol.IntentResume, "work", token)
1596- resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}
1596+ resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"}
15971597 resumeHello.TrueColor = true
15981598 _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
15991599 require.NoError(t, err)
@@ -1603,10 +1603,10 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
16031603 sess.mu.Lock()
16041604 future := sess.tabs[1].focusedPane()
16051605 futureTabID, futurePaneID := sess.tabs[1].stableID, future.stableID
1606- require.Equal(t, resumeHello.Env, sess.env)
1606+ require.Equal(t, []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"}, sess.env)
16071607 sess.mu.Unlock()
1608- require.Equal(t, []string{"/bin/sh", "/usr/bin/fish"}, commands)
1609- require.Equal(t, []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])
1608+ require.Equal(t, []string{"/bin/sh", "/bin/sh"}, commands)
1609+ require.Equal(t, []string{"SECRET=before", "SHELL=/bin/sh", "WAYLAND_DISPLAY=wayland-1", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])
16101610 }
16111611
16121612 func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {
@@ -1615,8 +1615,13 @@ func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {
16151615 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
16161616 d.baseEnv = []string{"DAEMON=owned"}
16171617 oldTransport := &closeTrackingTransport{}
1618- sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTransport)
1618+ created := helloResumeCapable(protocol.IntentNew, "work", 0)
1619+ created.Env = []string{"LOCAL=client"}
1620+ sess, ac, err := d.route(created, oldTransport)
16191621 require.NoError(t, err)
1622+ localEnv, owned := ac.clientEnvironment()
1623+ require.True(t, owned, "a client-owned route records the attachment environment")
1624+ require.Equal(t, []string{"LOCAL=client"}, localEnv)
16201625 sess.mu.Lock()
16211626 sess.env = copyEnvironment(d.baseEnv)
16221627 sess.mu.Unlock()
@@ -1633,6 +1638,8 @@ func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {
16331638 sess.mu.Lock()
16341639 require.Equal(t, []string{"DAEMON=owned"}, sess.env)
16351640 sess.mu.Unlock()
1641+ _, owned = ac.clientEnvironment()
1642+ require.False(t, owned, "a daemon-owned resume clears the attachment's client environment")
16361643
16371644 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
16381645 }
@@ -1561,7 +1561,7 @@ func TestSequencedInputDoesNotPrematurelyEcho(t *testing.T) {
1561 require.Zero(t, ac.echoAck.Load())1561 require.Zero(t, ac.echoAck.Load())
1562 }1562 }
1563 1563
1564-func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {1564+func TestResumeParkedRefreshesSessionBoundFuturePTYEnvironment(t *testing.T) {
1565 initialPTY, releaseInitial := newBlockingPTY(t)1565 initialPTY, releaseInitial := newBlockingPTY(t)
1566 futurePTY, releaseFuture := newBlockingPTY(t)1566 futurePTY, releaseFuture := newBlockingPTY(t)
1567 defer releaseInitial()1567 defer releaseInitial()
@@ -1585,7 +1585,7 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
1585 d := newTestDaemon(t, factory, stubClock{})1585 d := newTestDaemon(t, factory, stubClock{})
1586 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))1586 tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{}))
1587 hello := helloResumeCapable(protocol.IntentNew, "work", 0)1587 hello := helloResumeCapable(protocol.IntentNew, "work", 0)
1588- hello.Env = []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old"}1588+ hello.Env = []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old", "SSH_AUTH_SOCK=/run/old-agent"}
1589 sess, ac, err := d.route(hello, tr)1589 sess, ac, err := d.route(hello, tr)
1590 require.NoError(t, err)1590 require.NoError(t, err)
1591 token := ac.resumeToken1591 token := ac.resumeToken
@@ -1593,7 +1593,7 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
1593 require.True(t, d.parkAttachment(sess, ac))1593 require.True(t, d.parkAttachment(sess, ac))
1594 1594
1595 resumeHello := helloResumeCapable(protocol.IntentResume, "work", token)1595 resumeHello := helloResumeCapable(protocol.IntentResume, "work", token)
1596- resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old"}1596+ resumeHello.Env = []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=old", "COLORTERM=old", "TERM_PROGRAM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"}
1597 resumeHello.TrueColor = true1597 resumeHello.TrueColor = true
1598 _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})1598 _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
1599 require.NoError(t, err)1599 require.NoError(t, err)
@@ -1603,10 +1603,10 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) {
1603 sess.mu.Lock()1603 sess.mu.Lock()
1604 future := sess.tabs[1].focusedPane()1604 future := sess.tabs[1].focusedPane()
1605 futureTabID, futurePaneID := sess.tabs[1].stableID, future.stableID1605 futureTabID, futurePaneID := sess.tabs[1].stableID, future.stableID
1606- require.Equal(t, resumeHello.Env, sess.env)1606+ require.Equal(t, []string{"SECRET=before", "SHELL=/bin/sh", "TERM=old", "VEV=old", "WAYLAND_DISPLAY=wayland-1"}, sess.env)
1607 sess.mu.Unlock()1607 sess.mu.Unlock()
1608- require.Equal(t, []string{"/bin/sh", "/usr/bin/fish"}, commands)1608+ require.Equal(t, []string{"/bin/sh", "/bin/sh"}, commands)
1609- require.Equal(t, []string{"SECRET=after", "PAIR=a=b", "SHELL=/usr/bin/fish", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])1609+ require.Equal(t, []string{"SECRET=before", "SHELL=/bin/sh", "WAYLAND_DISPLAY=wayland-1", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + futureTabID + ",pane=" + futurePaneID}, envs[1])
1610 }1610 }
1611 1611
1612 func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {1612 func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {
@@ -1615,8 +1615,13 @@ func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {
1615 d := newTestDaemon(t, newFactory(t, pty), stubClock{})1615 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
1616 d.baseEnv = []string{"DAEMON=owned"}1616 d.baseEnv = []string{"DAEMON=owned"}
1617 oldTransport := &closeTrackingTransport{}1617 oldTransport := &closeTrackingTransport{}
1618- sess, ac, err := d.route(helloResumeCapable(protocol.IntentNew, "work", 0), oldTransport)1618+ created := helloResumeCapable(protocol.IntentNew, "work", 0)
1619+ created.Env = []string{"LOCAL=client"}
1620+ sess, ac, err := d.route(created, oldTransport)
1619 require.NoError(t, err)1621 require.NoError(t, err)
1622+ localEnv, owned := ac.clientEnvironment()
1623+ require.True(t, owned, "a client-owned route records the attachment environment")
1624+ require.Equal(t, []string{"LOCAL=client"}, localEnv)
1620 sess.mu.Lock()1625 sess.mu.Lock()
1621 sess.env = copyEnvironment(d.baseEnv)1626 sess.env = copyEnvironment(d.baseEnv)
1622 sess.mu.Unlock()1627 sess.mu.Unlock()
@@ -1633,6 +1638,8 @@ func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) {
1633 sess.mu.Lock()1638 sess.mu.Lock()
1634 require.Equal(t, []string{"DAEMON=owned"}, sess.env)1639 require.Equal(t, []string{"DAEMON=owned"}, sess.env)
1635 sess.mu.Unlock()1640 sess.mu.Unlock()
1641+ _, owned = ac.clientEnvironment()
1642+ require.False(t, owned, "a daemon-owned resume clears the attachment's client environment")
1636 1643
1637 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))1644 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
1638 }1645 }
M internal/usecase/daemon/same_peer_switch_test.go +54 -0
@@ -120,3 +120,57 @@ func TestSamePeerSwitchRejectsStaleTargetWithoutMutation(t *testing.T) {
120120 failure := decodeServerMessage(t, failureFrame).(protocol.SamePeerSwitchFailure)
121121 require.Equal(t, protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchStaleTarget}, failure)
122122 }
123+
124+func TestSamePeerSwitchRefreshesTargetFromSwitchingAttachment(t *testing.T) {
125+ targetEnv := []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-old", "SSH_AUTH_SOCK=/run/other-agent"}
126+ tests := []struct {
127+ name string
128+ clientEnv []string
129+ want []string
130+ }{
131+ {
132+ name: "local attachment refreshes session-bound variables",
133+ clientEnv: []string{"SHELL=/bin/bash", "WAYLAND_DISPLAY=wayland-1"},
134+ want: []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-1"},
135+ },
136+ {
137+ name: "daemon-owned attachment leaves the target untouched",
138+ want: targetEnv,
139+ },
140+ }
141+ for _, tt := range tests {
142+ t.Run(tt.name, func(t *testing.T) {
143+ d, source, ac, _, releases := newManualTabSession(t, 1)
144+ defer releaseAll(releases)
145+ ac.setClientEnvironment(tt.clientEnv)
146+
147+ lifecycle := domain.SessionLifecycleID{7}
148+ target := &session{
149+ sessionCore: sessionCore{id: "target", name: "target", incarnation: lifecycle, attachments: make(map[*attachedClient]struct{})},
150+ ctx: source.ctx,
151+ cancel: func() {},
152+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
153+ env: append([]string(nil), targetEnv...),
154+ }
155+ publishTiledPaneOwners(target, target.tabs[0])
156+ d.mu.Lock()
157+ d.sessions[target.id] = target
158+ d.mu.Unlock()
159+ ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
160+
161+ token := source.captureAttachmentCapability(ac, ac.transport())
162+ effect, admitted := ac.beginAttachmentEffect(token)
163+ require.True(t, admitted)
164+ defer effect.End()
165+ requestTarget := protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}
166+ ac.offerSamePeerTarget(requestTarget)
167+
168+ d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{RequestID: 1, Target: requestTarget})
169+
170+ require.Same(t, target, ac.currentAttachmentSession())
171+ target.mu.Lock()
172+ defer target.mu.Unlock()
173+ require.Equal(t, tt.want, target.env)
174+ })
175+ }
176+}
@@ -120,3 +120,57 @@ func TestSamePeerSwitchRejectsStaleTargetWithoutMutation(t *testing.T) {
120 failure := decodeServerMessage(t, failureFrame).(protocol.SamePeerSwitchFailure)120 failure := decodeServerMessage(t, failureFrame).(protocol.SamePeerSwitchFailure)
121 require.Equal(t, protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchStaleTarget}, failure)121 require.Equal(t, protocol.SamePeerSwitchFailure{RequestID: 1, Code: protocol.SamePeerSwitchStaleTarget}, failure)
122 }122 }
123+
124+func TestSamePeerSwitchRefreshesTargetFromSwitchingAttachment(t *testing.T) {
125+ targetEnv := []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-old", "SSH_AUTH_SOCK=/run/other-agent"}
126+ tests := []struct {
127+ name string
128+ clientEnv []string
129+ want []string
130+ }{
131+ {
132+ name: "local attachment refreshes session-bound variables",
133+ clientEnv: []string{"SHELL=/bin/bash", "WAYLAND_DISPLAY=wayland-1"},
134+ want: []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-1"},
135+ },
136+ {
137+ name: "daemon-owned attachment leaves the target untouched",
138+ want: targetEnv,
139+ },
140+ }
141+ for _, tt := range tests {
142+ t.Run(tt.name, func(t *testing.T) {
143+ d, source, ac, _, releases := newManualTabSession(t, 1)
144+ defer releaseAll(releases)
145+ ac.setClientEnvironment(tt.clientEnv)
146+
147+ lifecycle := domain.SessionLifecycleID{7}
148+ target := &session{
149+ sessionCore: sessionCore{id: "target", name: "target", incarnation: lifecycle, attachments: make(map[*attachedClient]struct{})},
150+ ctx: source.ctx,
151+ cancel: func() {},
152+ tabs: []*tab{newTab(nil, domain.Size{Cols: 80, Rows: 23})},
153+ env: append([]string(nil), targetEnv...),
154+ }
155+ publishTiledPaneOwners(target, target.tabs[0])
156+ d.mu.Lock()
157+ d.sessions[target.id] = target
158+ d.mu.Unlock()
159+ ac.setRouteSnapshot(protocol.RecentRouteSnapshot{Generation: 1})
160+
161+ token := source.captureAttachmentCapability(ac, ac.transport())
162+ effect, admitted := ac.beginAttachmentEffect(token)
163+ require.True(t, admitted)
164+ defer effect.End()
165+ requestTarget := protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "target"}
166+ ac.offerSamePeerTarget(requestTarget)
167+
168+ d.switchSamePeerForAttachment(effect, protocol.SamePeerSwitchRequest{RequestID: 1, Target: requestTarget})
169+
170+ require.Same(t, target, ac.currentAttachmentSession())
171+ target.mu.Lock()
172+ defer target.mu.Unlock()
173+ require.Equal(t, tt.want, target.env)
174+ })
175+ }
176+}
M internal/usecase/daemon/session.go +31 -9
@@ -63,9 +63,16 @@ type session struct {
6363 cwd string
6464 // env is the authoritative immutable environment snapshot for future PTY children.
6565 // It is guarded by mu and is always copied on ingress and egress.
66- env []string
67- snapDirty atomic.Bool
68- snapEligible atomic.Bool
66+ env []string
67+ // envProvisional marks an env that came from the daemon's own process
68+ // rather than a client: a snapshot restore, a daemon-owned resume of a
69+ // stopped session, or a session created or resumed from a provisional
70+ // one. The first client-owned environment then replaces it (see
71+ // adoptClientEnvironmentLocked for the display-group exception); later
72+ // ones refresh only session-bound variables. Guarded by mu.
73+ envProvisional bool
74+ snapDirty atomic.Bool
75+ snapEligible atomic.Bool
6976 // snapshotMu serializes mutation revisions and repository publication
7077 // generations with worker completion. It is intentionally independent from
7178 // mu: persistence never holds session state locks while encoding or writing.
@@ -480,7 +487,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
480487 }
481488 from.mu.Lock()
482489 cwd := from.cwd
483- env := copyEnvironment(from.env)
490+ env, provisional := from.environmentSeedLocked()
484491 _, attached := from.attachments[ac]
485492 from.mu.Unlock()
486493 if !attached {
@@ -489,6 +496,9 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
489496 }
490497
491498 newSess, err := d.createSessionLockedWithMode(name, false, cwd, geometry, env)
499+ if err == nil && provisional {
500+ markEnvironmentProvisional(newSess)
501+ }
492502 d.mu.Unlock()
493503 if err != nil {
494504 return err
@@ -537,7 +547,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(effect *attachmentEffect, n
537547 source: capability.sess, next: capability.ac,
538548
539549 expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-session",
540- copySourceEnvironment: true, ready: true,
550+ ready: true,
541551 createTargetLocked: func() (*session, error) {
542552 source := capability.sess
543553 if source == nil {
@@ -553,10 +563,14 @@ func (d *Daemon) createSessionAndSwitchForAttachment(effect *attachmentEffect, n
553563 return nil, errSessionNameInUse
554564 }
555565 source.mu.Lock()
556- cwd, env := source.cwd, copyEnvironment(source.env)
566+ cwd := source.cwd
567+ env, provisional := source.environmentSeedLocked()
557568 source.mu.Unlock()
558569 var createErr error
559570 created, createErr = d.createSessionLockedWithMode(name, false, cwd, capability.ac.geometrySnapshot(), env)
571+ if createErr == nil && provisional {
572+ markEnvironmentProvisional(created)
573+ }
560574 return created, createErr
561575 },
562576 })
@@ -586,7 +600,8 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
586600 return errPurgeAdmissionClosed
587601 }
588602 from.mu.Lock()
589- cwd, env := from.cwd, copyEnvironment(from.env)
603+ cwd := from.cwd
604+ env, provisional := from.environmentSeedLocked()
590605 _, attached := from.attachments[ac]
591606 from.mu.Unlock()
592607 if !attached {
@@ -595,6 +610,9 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
595610 }
596611 name := d.allocEphemeralNameLocked()
597612 newSess, err := d.createSessionLockedWithMode(name, true, cwd, geometry, env)
613+ if err == nil && provisional {
614+ markEnvironmentProvisional(newSess)
615+ }
598616 d.mu.Unlock()
599617 if err != nil {
600618 return err
@@ -626,7 +644,7 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachment
626644 transition, err := d.transitionAttachment(attachmentTransitionRequest{
627645 source: capability.sess, next: capability.ac,
628646 expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-ephemeral-session",
629- copySourceEnvironment: true, ready: true,
647+ ready: true,
630648 createTargetLocked: func() (*session, error) {
631649 if capability.sess == nil || d.closing {
632650 return nil, errAttachmentTransition
@@ -636,11 +654,15 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachment
636654 }
637655 source := capability.sess
638656 source.mu.Lock()
639- cwd, env := source.cwd, copyEnvironment(source.env)
657+ cwd := source.cwd
658+ env, provisional := source.environmentSeedLocked()
640659 source.mu.Unlock()
641660 name := d.allocEphemeralNameLocked()
642661 var createErr error
643662 created, createErr = d.createSessionLockedWithMode(name, true, cwd, capability.ac.geometrySnapshot(), env)
663+ if createErr == nil && provisional {
664+ markEnvironmentProvisional(created)
665+ }
644666 return created, createErr
645667 },
646668 })
@@ -63,9 +63,16 @@ type session struct {
63 cwd string63 cwd string
64 // env is the authoritative immutable environment snapshot for future PTY children.64 // env is the authoritative immutable environment snapshot for future PTY children.
65 // It is guarded by mu and is always copied on ingress and egress.65 // It is guarded by mu and is always copied on ingress and egress.
66- env []string66+ env []string
67- snapDirty atomic.Bool67+ // envProvisional marks an env that came from the daemon's own process
68- snapEligible atomic.Bool68+ // rather than a client: a snapshot restore, a daemon-owned resume of a
69+ // stopped session, or a session created or resumed from a provisional
70+ // one. The first client-owned environment then replaces it (see
71+ // adoptClientEnvironmentLocked for the display-group exception); later
72+ // ones refresh only session-bound variables. Guarded by mu.
73+ envProvisional bool
74+ snapDirty atomic.Bool
75+ snapEligible atomic.Bool
69 // snapshotMu serializes mutation revisions and repository publication76 // snapshotMu serializes mutation revisions and repository publication
70 // generations with worker completion. It is intentionally independent from77 // generations with worker completion. It is intentionally independent from
71 // mu: persistence never holds session state locks while encoding or writing.78 // mu: persistence never holds session state locks while encoding or writing.
@@ -480,7 +487,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
480 }487 }
481 from.mu.Lock()488 from.mu.Lock()
482 cwd := from.cwd489 cwd := from.cwd
483- env := copyEnvironment(from.env)490+ env, provisional := from.environmentSeedLocked()
484 _, attached := from.attachments[ac]491 _, attached := from.attachments[ac]
485 from.mu.Unlock()492 from.mu.Unlock()
486 if !attached {493 if !attached {
@@ -489,6 +496,9 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
489 }496 }
490 497
491 newSess, err := d.createSessionLockedWithMode(name, false, cwd, geometry, env)498 newSess, err := d.createSessionLockedWithMode(name, false, cwd, geometry, env)
499+ if err == nil && provisional {
500+ markEnvironmentProvisional(newSess)
501+ }
492 d.mu.Unlock()502 d.mu.Unlock()
493 if err != nil {503 if err != nil {
494 return err504 return err
@@ -537,7 +547,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(effect *attachmentEffect, n
537 source: capability.sess, next: capability.ac,547 source: capability.sess, next: capability.ac,
538 548
539 expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-session",549 expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-session",
540- copySourceEnvironment: true, ready: true,550+ ready: true,
541 createTargetLocked: func() (*session, error) {551 createTargetLocked: func() (*session, error) {
542 source := capability.sess552 source := capability.sess
543 if source == nil {553 if source == nil {
@@ -553,10 +563,14 @@ func (d *Daemon) createSessionAndSwitchForAttachment(effect *attachmentEffect, n
553 return nil, errSessionNameInUse563 return nil, errSessionNameInUse
554 }564 }
555 source.mu.Lock()565 source.mu.Lock()
556- cwd, env := source.cwd, copyEnvironment(source.env)566+ cwd := source.cwd
567+ env, provisional := source.environmentSeedLocked()
557 source.mu.Unlock()568 source.mu.Unlock()
558 var createErr error569 var createErr error
559 created, createErr = d.createSessionLockedWithMode(name, false, cwd, capability.ac.geometrySnapshot(), env)570 created, createErr = d.createSessionLockedWithMode(name, false, cwd, capability.ac.geometrySnapshot(), env)
571+ if createErr == nil && provisional {
572+ markEnvironmentProvisional(created)
573+ }
560 return created, createErr574 return created, createErr
561 },575 },
562 })576 })
@@ -586,7 +600,8 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
586 return errPurgeAdmissionClosed600 return errPurgeAdmissionClosed
587 }601 }
588 from.mu.Lock()602 from.mu.Lock()
589- cwd, env := from.cwd, copyEnvironment(from.env)603+ cwd := from.cwd
604+ env, provisional := from.environmentSeedLocked()
590 _, attached := from.attachments[ac]605 _, attached := from.attachments[ac]
591 from.mu.Unlock()606 from.mu.Unlock()
592 if !attached {607 if !attached {
@@ -595,6 +610,9 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie
595 }610 }
596 name := d.allocEphemeralNameLocked()611 name := d.allocEphemeralNameLocked()
597 newSess, err := d.createSessionLockedWithMode(name, true, cwd, geometry, env)612 newSess, err := d.createSessionLockedWithMode(name, true, cwd, geometry, env)
613+ if err == nil && provisional {
614+ markEnvironmentProvisional(newSess)
615+ }
598 d.mu.Unlock()616 d.mu.Unlock()
599 if err != nil {617 if err != nil {
600 return err618 return err
@@ -626,7 +644,7 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachment
626 transition, err := d.transitionAttachment(attachmentTransitionRequest{644 transition, err := d.transitionAttachment(attachmentTransitionRequest{
627 source: capability.sess, next: capability.ac,645 source: capability.sess, next: capability.ac,
628 expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-ephemeral-session",646 expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-ephemeral-session",
629- copySourceEnvironment: true, ready: true,647+ ready: true,
630 createTargetLocked: func() (*session, error) {648 createTargetLocked: func() (*session, error) {
631 if capability.sess == nil || d.closing {649 if capability.sess == nil || d.closing {
632 return nil, errAttachmentTransition650 return nil, errAttachmentTransition
@@ -636,11 +654,15 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachment
636 }654 }
637 source := capability.sess655 source := capability.sess
638 source.mu.Lock()656 source.mu.Lock()
639- cwd, env := source.cwd, copyEnvironment(source.env)657+ cwd := source.cwd
658+ env, provisional := source.environmentSeedLocked()
640 source.mu.Unlock()659 source.mu.Unlock()
641 name := d.allocEphemeralNameLocked()660 name := d.allocEphemeralNameLocked()
642 var createErr error661 var createErr error
643 created, createErr = d.createSessionLockedWithMode(name, true, cwd, capability.ac.geometrySnapshot(), env)662 created, createErr = d.createSessionLockedWithMode(name, true, cwd, capability.ac.geometrySnapshot(), env)
663+ if createErr == nil && provisional {
664+ markEnvironmentProvisional(created)
665+ }
644 return created, createErr666 return created, createErr
645 },667 },
646 })668 })
M internal/usecase/daemon/session_admission_test.go +48 -6
@@ -554,11 +554,10 @@ func TestRouteLocalCreateUsesAdmittedClientEnvironment(t *testing.T) {
554554 }
555555
556556 // 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.
557+// attach refreshes the session-bound variables for future PTY children while
558+// the already-running PTY and ordinary variables are untouched.
560559 func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) {
561- firstEnv := []string{"MARK=first"}
560+ firstEnv := []string{"MARK=first", "WAYLAND_DISPLAY=wayland-old"}
562561 pty, release := newBlockingPTY(t)
563562 defer release()
564563 d := newTestDaemon(t, newFactorySeq(t, pty, newQuietPTY()), stubClock{})
@@ -581,7 +580,7 @@ func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) {
581580 // A later attach refreshes only the session's future-child environment. A
582581 // named admission authorizes creation only, so the attach rides a fresh
583582 // exact admission for the live session's own lifecycle.
584- attachEnv := []string{"MARK=attach"}
583+ attachEnv := []string{"MARK=attach", "WAYLAND_DISPLAY=wayland-new"}
585584 target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
586585 attach := protocol.Hello{
587586 Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, Size: defaultSize,
@@ -595,13 +594,56 @@ func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) {
595594 require.NoError(t, err)
596595
597596 sess.mu.Lock()
598- require.Equal(t, attachEnv, sess.env, "a later client-owned attach refreshes future child environment")
597+ require.Equal(t, []string{"MARK=first", "WAYLAND_DISPLAY=wayland-new"}, sess.env, "a later client-owned attach refreshes only session-bound variables")
599598 require.Same(t, existingPane, sess.tabs[0].focusedPane(), "the existing PTY is untouched")
600599 sess.mu.Unlock()
601600
602601 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
603602 }
604603
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.
608+func 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+
605647 // TestRouteRemoteExactAttachPreservesSessionEnvironment proves a remote exact
606648 // attach never rewrites the live session's environment or working directory:
607649 // the daemon-owned session keeps what it was created with, and only a
@@ -554,11 +554,10 @@ func TestRouteLocalCreateUsesAdmittedClientEnvironment(t *testing.T) {
554 }554 }
555 555
556 // TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment proves a local admitted556 // TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment proves a local admitted
557-// attach refreshes the session's environment for future PTY children while the557+// attach refreshes the session-bound variables for future PTY children while
558-// already-running PTY is untouched, which is the existing shared-session558+// the already-running PTY and ordinary variables are untouched.
559-// semantics the admission must preserve.
560 func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) {559 func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) {
561- firstEnv := []string{"MARK=first"}560+ firstEnv := []string{"MARK=first", "WAYLAND_DISPLAY=wayland-old"}
562 pty, release := newBlockingPTY(t)561 pty, release := newBlockingPTY(t)
563 defer release()562 defer release()
564 d := newTestDaemon(t, newFactorySeq(t, pty, newQuietPTY()), stubClock{})563 d := newTestDaemon(t, newFactorySeq(t, pty, newQuietPTY()), stubClock{})
@@ -581,7 +580,7 @@ func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) {
581 // A later attach refreshes only the session's future-child environment. A580 // A later attach refreshes only the session's future-child environment. A
582 // named admission authorizes creation only, so the attach rides a fresh581 // named admission authorizes creation only, so the attach rides a fresh
583 // exact admission for the live session's own lifecycle.582 // exact admission for the live session's own lifecycle.
584- attachEnv := []string{"MARK=attach"}583+ attachEnv := []string{"MARK=attach", "WAYLAND_DISPLAY=wayland-new"}
585 target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}584 target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
586 attach := protocol.Hello{585 attach := protocol.Hello{
587 Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, Size: defaultSize,586 Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, Size: defaultSize,
@@ -595,13 +594,56 @@ func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) {
595 require.NoError(t, err)594 require.NoError(t, err)
596 595
597 sess.mu.Lock()596 sess.mu.Lock()
598- require.Equal(t, attachEnv, sess.env, "a later client-owned attach refreshes future child environment")597+ require.Equal(t, []string{"MARK=first", "WAYLAND_DISPLAY=wayland-new"}, sess.env, "a later client-owned attach refreshes only session-bound variables")
599 require.Same(t, existingPane, sess.tabs[0].focusedPane(), "the existing PTY is untouched")598 require.Same(t, existingPane, sess.tabs[0].focusedPane(), "the existing PTY is untouched")
600 sess.mu.Unlock()599 sess.mu.Unlock()
601 600
602 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))601 require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true))
603 }602 }
604 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.
608+func 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+
605 // TestRouteRemoteExactAttachPreservesSessionEnvironment proves a remote exact647 // TestRouteRemoteExactAttachPreservesSessionEnvironment proves a remote exact
606 // attach never rewrites the live session's environment or working directory:648 // attach never rewrites the live session's environment or working directory:
607 // the daemon-owned session keeps what it was created with, and only a649 // the daemon-owned session keeps what it was created with, and only a
A internal/usecase/daemon/session_env.go +229 -0
@@ -0,0 +1,229 @@
1+package daemon
2+
3+import (
4+ "encoding/json"
5+ "slices"
6+ "strings"
7+
8+ "github.com/bnema/vev/internal/protocol"
9+)
10+
11+// Display variables describe one graphical session and move as a group: a
12+// client with a non-empty WAYLAND_DISPLAY or DISPLAY replaces the whole group and removes
13+// members it lacks, so a switch to another compositor or an X11-only desktop
14+// never leaves a stale socket behind. A client without a display (console)
15+// or reached over SSH leaves the group untouched: an X-forwarded DISPLAY
16+// belongs to that one connection and must not replace the local desktop.
17+var sessionDisplayEnvironment = []string{
18+ "WAYLAND_DISPLAY",
19+ "DISPLAY",
20+ "XAUTHORITY",
21+ "XDG_CURRENT_DESKTOP",
22+ "XDG_SESSION_TYPE",
23+ "XDG_SESSION_DESKTOP",
24+ "XDG_SESSION_CLASS",
25+ "XDG_SESSION_ID",
26+}
27+
28+// User variables belong to the user login rather than one display: with
29+// systemd, every graphical session of a user shares the runtime dir and user
30+// bus. They are set when the client has them and never removed.
31+var sessionUserEnvironment = []string{
32+ "DBUS_SESSION_BUS_ADDRESS",
33+ "XDG_RUNTIME_DIR",
34+}
35+
36+// Login variables follow the latest attach exactly: a value the client lacks is
37+// removed, because a stale agent socket or SSH peer is worse than none.
38+var sessionLoginEnvironment = []string{
39+ "SSH_AUTH_SOCK",
40+ "SSH_AGENT_PID",
41+ "SSH_CONNECTION",
42+ "SSH_CLIENT",
43+ "SSH_TTY",
44+}
45+
46+// setClientEnvironment records the attachment's own client-owned environment;
47+// nil means the daemon owns it and the attachment never changes session env.
48+func (ac *attachedClient) setClientEnvironment(env []string) {
49+ if env == nil {
50+ ac.clientEnv.Store(nil)
51+ return
52+ }
53+ owned := append(make([]string, 0, len(env)), env...)
54+ ac.clientEnv.Store(&owned)
55+}
56+
57+// clientEnvironment returns the attachment's client-owned environment, or
58+// ok=false when the daemon owns it. The slice is immutable; do not modify it.
59+func (ac *attachedClient) clientEnvironment() ([]string, bool) {
60+ env := ac.clientEnv.Load()
61+ if env == nil {
62+ return nil, false
63+ }
64+ return *env, true
65+}
66+
67+// helloClientEnvironment returns the Hello environment when the client owns
68+// it, or nil for a daemon-owned (remote) attach.
69+func helloClientEnvironment(h protocol.Hello) []string {
70+ if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
71+ return nil
72+ }
73+ if h.Env == nil {
74+ return []string{}
75+ }
76+ return h.Env
77+}
78+
79+// environmentSeedLocked returns a copy of the session environment for a new
80+// session seeded from this one, and whether it is still the daemon's own
81+// (provisional). The caller holds s.mu.
82+func (s *session) environmentSeedLocked() ([]string, bool) {
83+ return copyEnvironment(s.env), s.envProvisional
84+}
85+
86+// markEnvironmentProvisional records that sess.env came from the daemon's own
87+// process, so the first client-owned environment replaces it.
88+func markEnvironmentProvisional(sess *session) {
89+ sess.mu.Lock()
90+ sess.envProvisional = true
91+ sess.mu.Unlock()
92+}
93+
94+// adoptClientEnvironmentLocked applies a client-owned environment to the
95+// session's future PTY children. The caller holds sess.mu.
96+//
97+// A provisional environment (the daemon's own, see envProvisional) is replaced
98+// wholesale, except that a client without its own display keeps the session's
99+// display group when it has one, matching refreshSessionEnvironment.
100+func (s *session) adoptClientEnvironmentLocked(client []string) {
101+ if !s.envProvisional {
102+ s.env = refreshSessionEnvironment(s.env, client)
103+ return
104+ }
105+ next := copyEnvironment(client)
106+ if !ownsDisplay(client) && hasDisplay(s.env) {
107+ next = replaceEnvironmentGroup(next, s.env, sessionDisplayEnvironment)
108+ }
109+ s.env = next
110+ s.envProvisional = false
111+}
112+
113+// refreshSessionEnvironment returns current with its session-bound variables
114+// refreshed from client. Entries keep their position; duplicates of a refreshed
115+// variable collapse into its first entry, and new variables are appended.
116+func refreshSessionEnvironment(current, client []string) []string {
117+ if ownsDisplay(client) {
118+ current = replaceEnvironmentGroup(current, client, sessionDisplayEnvironment)
119+ }
120+ for _, key := range sessionUserEnvironment {
121+ if value, ok := lookupEnvironment(client, key); ok {
122+ current = setEnvironment(current, key, &value)
123+ }
124+ }
125+ return replaceEnvironmentGroup(current, client, sessionLoginEnvironment)
126+}
127+
128+// replaceEnvironmentGroup sets every key of group in env to its value in
129+// source, removing keys source lacks. It returns a new slice.
130+func replaceEnvironmentGroup(env, source, group []string) []string {
131+ for _, key := range group {
132+ if value, ok := lookupEnvironment(source, key); ok {
133+ env = setEnvironment(env, key, &value)
134+ } else {
135+ env = setEnvironment(env, key, nil)
136+ }
137+ }
138+ return env
139+}
140+
141+// setEnvironment returns a copy of env with key set to *value in place of its
142+// first entry (appended when absent), or removed when value is nil. Later
143+// duplicates of key are dropped. env itself is never modified.
144+func setEnvironment(env []string, key string, value *string) []string {
145+ out := make([]string, 0, len(env)+1)
146+ found := false
147+ for _, entry := range env {
148+ if name, _, _ := environmentEntry(entry); name == key {
149+ if !found && value != nil {
150+ out = append(out, key+"="+*value)
151+ }
152+ found = true
153+ continue
154+ }
155+ out = append(out, entry)
156+ }
157+ if !found && value != nil {
158+ out = append(out, key+"="+*value)
159+ }
160+ return out
161+}
162+
163+// ownsDisplay reports whether client is a local graphical session whose
164+// display group should replace the session's. SSH clients never do, even with
165+// X forwarding.
166+func ownsDisplay(client []string) bool {
167+ if _, ssh := lookupEnvironment(client, "SSH_CONNECTION"); ssh {
168+ return false
169+ }
170+ return hasDisplay(client)
171+}
172+
173+// hasDisplay reports whether env belongs to a graphical session. An empty
174+// value names no display (it usually means "no X here"), so it does not count.
175+func hasDisplay(env []string) bool {
176+ wayland, _ := lookupEnvironment(env, "WAYLAND_DISPLAY")
177+ x11, _ := lookupEnvironment(env, "DISPLAY")
178+ return wayland != "" || x11 != ""
179+}
180+
181+// sessionBoundEnvironment lists the variables that follow the attaching client.
182+// A local client-owned attach refreshes only these; every other variable
183+// (SHELL, PATH, HOME, ...) keeps the value the session was created with.
184+var sessionBoundEnvironment = slices.Concat(sessionDisplayEnvironment, sessionUserEnvironment, sessionLoginEnvironment)
185+
186+// lookupEnvironment returns the first value for key, matching getenv(3).
187+func lookupEnvironment(env []string, key string) (string, bool) {
188+ for _, entry := range env {
189+ if name, value, ok := environmentEntry(entry); ok && name == key {
190+ return value, true
191+ }
192+ }
193+ return "", false
194+}
195+
196+// sessionEnvironmentExport renders the session-bound variables a running shell
197+// should apply. JSON maps each variable to its value, or null when the shell
198+// should unset it; it is the machine format. Text output is one KEY=value line
199+// per set variable, for people.
200+// Missing login variables are always unset. Missing display variables are
201+// unset only when the session has a display; otherwise they are omitted, so a
202+// shell keeps any value it obtained on its own.
203+func sessionEnvironmentExport(env []string, asJSON bool) (string, error) {
204+ values := make(map[string]*string)
205+ var text strings.Builder
206+ for _, key := range sessionBoundEnvironment {
207+ if value, ok := lookupEnvironment(env, key); ok {
208+ values[key] = &value
209+ text.WriteString(key + "=" + value + "\n")
210+ }
211+ }
212+ unset := sessionLoginEnvironment
213+ if hasDisplay(env) {
214+ unset = slices.Concat(sessionDisplayEnvironment, sessionLoginEnvironment)
215+ }
216+ for _, key := range unset {
217+ if _, ok := values[key]; !ok {
218+ values[key] = nil
219+ }
220+ }
221+ if !asJSON {
222+ return text.String(), nil
223+ }
224+ encoded, err := json.Marshal(values)
225+ if err != nil {
226+ return "", err
227+ }
228+ return string(encoded) + "\n", nil
229+}
@@ -0,0 +1,229 @@
1+package daemon
2+
3+import (
4+ "encoding/json"
5+ "slices"
6+ "strings"
7+
8+ "github.com/bnema/vev/internal/protocol"
9+)
10+
11+// Display variables describe one graphical session and move as a group: a
12+// client with a non-empty WAYLAND_DISPLAY or DISPLAY replaces the whole group and removes
13+// members it lacks, so a switch to another compositor or an X11-only desktop
14+// never leaves a stale socket behind. A client without a display (console)
15+// or reached over SSH leaves the group untouched: an X-forwarded DISPLAY
16+// belongs to that one connection and must not replace the local desktop.
17+var sessionDisplayEnvironment = []string{
18+ "WAYLAND_DISPLAY",
19+ "DISPLAY",
20+ "XAUTHORITY",
21+ "XDG_CURRENT_DESKTOP",
22+ "XDG_SESSION_TYPE",
23+ "XDG_SESSION_DESKTOP",
24+ "XDG_SESSION_CLASS",
25+ "XDG_SESSION_ID",
26+}
27+
28+// User variables belong to the user login rather than one display: with
29+// systemd, every graphical session of a user shares the runtime dir and user
30+// bus. They are set when the client has them and never removed.
31+var sessionUserEnvironment = []string{
32+ "DBUS_SESSION_BUS_ADDRESS",
33+ "XDG_RUNTIME_DIR",
34+}
35+
36+// Login variables follow the latest attach exactly: a value the client lacks is
37+// removed, because a stale agent socket or SSH peer is worse than none.
38+var sessionLoginEnvironment = []string{
39+ "SSH_AUTH_SOCK",
40+ "SSH_AGENT_PID",
41+ "SSH_CONNECTION",
42+ "SSH_CLIENT",
43+ "SSH_TTY",
44+}
45+
46+// setClientEnvironment records the attachment's own client-owned environment;
47+// nil means the daemon owns it and the attachment never changes session env.
48+func (ac *attachedClient) setClientEnvironment(env []string) {
49+ if env == nil {
50+ ac.clientEnv.Store(nil)
51+ return
52+ }
53+ owned := append(make([]string, 0, len(env)), env...)
54+ ac.clientEnv.Store(&owned)
55+}
56+
57+// clientEnvironment returns the attachment's client-owned environment, or
58+// ok=false when the daemon owns it. The slice is immutable; do not modify it.
59+func (ac *attachedClient) clientEnvironment() ([]string, bool) {
60+ env := ac.clientEnv.Load()
61+ if env == nil {
62+ return nil, false
63+ }
64+ return *env, true
65+}
66+
67+// helloClientEnvironment returns the Hello environment when the client owns
68+// it, or nil for a daemon-owned (remote) attach.
69+func helloClientEnvironment(h protocol.Hello) []string {
70+ if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned {
71+ return nil
72+ }
73+ if h.Env == nil {
74+ return []string{}
75+ }
76+ return h.Env
77+}
78+
79+// environmentSeedLocked returns a copy of the session environment for a new
80+// session seeded from this one, and whether it is still the daemon's own
81+// (provisional). The caller holds s.mu.
82+func (s *session) environmentSeedLocked() ([]string, bool) {
83+ return copyEnvironment(s.env), s.envProvisional
84+}
85+
86+// markEnvironmentProvisional records that sess.env came from the daemon's own
87+// process, so the first client-owned environment replaces it.
88+func markEnvironmentProvisional(sess *session) {
89+ sess.mu.Lock()
90+ sess.envProvisional = true
91+ sess.mu.Unlock()
92+}
93+
94+// adoptClientEnvironmentLocked applies a client-owned environment to the
95+// session's future PTY children. The caller holds sess.mu.
96+//
97+// A provisional environment (the daemon's own, see envProvisional) is replaced
98+// wholesale, except that a client without its own display keeps the session's
99+// display group when it has one, matching refreshSessionEnvironment.
100+func (s *session) adoptClientEnvironmentLocked(client []string) {
101+ if !s.envProvisional {
102+ s.env = refreshSessionEnvironment(s.env, client)
103+ return
104+ }
105+ next := copyEnvironment(client)
106+ if !ownsDisplay(client) && hasDisplay(s.env) {
107+ next = replaceEnvironmentGroup(next, s.env, sessionDisplayEnvironment)
108+ }
109+ s.env = next
110+ s.envProvisional = false
111+}
112+
113+// refreshSessionEnvironment returns current with its session-bound variables
114+// refreshed from client. Entries keep their position; duplicates of a refreshed
115+// variable collapse into its first entry, and new variables are appended.
116+func refreshSessionEnvironment(current, client []string) []string {
117+ if ownsDisplay(client) {
118+ current = replaceEnvironmentGroup(current, client, sessionDisplayEnvironment)
119+ }
120+ for _, key := range sessionUserEnvironment {
121+ if value, ok := lookupEnvironment(client, key); ok {
122+ current = setEnvironment(current, key, &value)
123+ }
124+ }
125+ return replaceEnvironmentGroup(current, client, sessionLoginEnvironment)
126+}
127+
128+// replaceEnvironmentGroup sets every key of group in env to its value in
129+// source, removing keys source lacks. It returns a new slice.
130+func replaceEnvironmentGroup(env, source, group []string) []string {
131+ for _, key := range group {
132+ if value, ok := lookupEnvironment(source, key); ok {
133+ env = setEnvironment(env, key, &value)
134+ } else {
135+ env = setEnvironment(env, key, nil)
136+ }
137+ }
138+ return env
139+}
140+
141+// setEnvironment returns a copy of env with key set to *value in place of its
142+// first entry (appended when absent), or removed when value is nil. Later
143+// duplicates of key are dropped. env itself is never modified.
144+func setEnvironment(env []string, key string, value *string) []string {
145+ out := make([]string, 0, len(env)+1)
146+ found := false
147+ for _, entry := range env {
148+ if name, _, _ := environmentEntry(entry); name == key {
149+ if !found && value != nil {
150+ out = append(out, key+"="+*value)
151+ }
152+ found = true
153+ continue
154+ }
155+ out = append(out, entry)
156+ }
157+ if !found && value != nil {
158+ out = append(out, key+"="+*value)
159+ }
160+ return out
161+}
162+
163+// ownsDisplay reports whether client is a local graphical session whose
164+// display group should replace the session's. SSH clients never do, even with
165+// X forwarding.
166+func ownsDisplay(client []string) bool {
167+ if _, ssh := lookupEnvironment(client, "SSH_CONNECTION"); ssh {
168+ return false
169+ }
170+ return hasDisplay(client)
171+}
172+
173+// hasDisplay reports whether env belongs to a graphical session. An empty
174+// value names no display (it usually means "no X here"), so it does not count.
175+func hasDisplay(env []string) bool {
176+ wayland, _ := lookupEnvironment(env, "WAYLAND_DISPLAY")
177+ x11, _ := lookupEnvironment(env, "DISPLAY")
178+ return wayland != "" || x11 != ""
179+}
180+
181+// sessionBoundEnvironment lists the variables that follow the attaching client.
182+// A local client-owned attach refreshes only these; every other variable
183+// (SHELL, PATH, HOME, ...) keeps the value the session was created with.
184+var sessionBoundEnvironment = slices.Concat(sessionDisplayEnvironment, sessionUserEnvironment, sessionLoginEnvironment)
185+
186+// lookupEnvironment returns the first value for key, matching getenv(3).
187+func lookupEnvironment(env []string, key string) (string, bool) {
188+ for _, entry := range env {
189+ if name, value, ok := environmentEntry(entry); ok && name == key {
190+ return value, true
191+ }
192+ }
193+ return "", false
194+}
195+
196+// sessionEnvironmentExport renders the session-bound variables a running shell
197+// should apply. JSON maps each variable to its value, or null when the shell
198+// should unset it; it is the machine format. Text output is one KEY=value line
199+// per set variable, for people.
200+// Missing login variables are always unset. Missing display variables are
201+// unset only when the session has a display; otherwise they are omitted, so a
202+// shell keeps any value it obtained on its own.
203+func sessionEnvironmentExport(env []string, asJSON bool) (string, error) {
204+ values := make(map[string]*string)
205+ var text strings.Builder
206+ for _, key := range sessionBoundEnvironment {
207+ if value, ok := lookupEnvironment(env, key); ok {
208+ values[key] = &value
209+ text.WriteString(key + "=" + value + "\n")
210+ }
211+ }
212+ unset := sessionLoginEnvironment
213+ if hasDisplay(env) {
214+ unset = slices.Concat(sessionDisplayEnvironment, sessionLoginEnvironment)
215+ }
216+ for _, key := range unset {
217+ if _, ok := values[key]; !ok {
218+ values[key] = nil
219+ }
220+ }
221+ if !asJSON {
222+ return text.String(), nil
223+ }
224+ encoded, err := json.Marshal(values)
225+ if err != nil {
226+ return "", err
227+ }
228+ return string(encoded) + "\n", nil
229+}
A internal/usecase/daemon/session_env_test.go +398 -0
@@ -0,0 +1,398 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/protocol"
8+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRefreshSessionEnvironment(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ current []string
16+ client []string
17+ want []string
18+ }{
19+ {
20+ name: "refreshing from the creating environment keeps it byte-identical",
21+ current: []string{"A=1", "WAYLAND_DISPLAY=wayland-1", "PATH=/a", "DBUS_SESSION_BUS_ADDRESS=bus", "SSH_AUTH_SOCK=/agent", "Z=2"},
22+ client: []string{"A=1", "WAYLAND_DISPLAY=wayland-1", "PATH=/a", "DBUS_SESSION_BUS_ADDRESS=bus", "SSH_AUTH_SOCK=/agent", "Z=2"},
23+ want: []string{"A=1", "WAYLAND_DISPLAY=wayland-1", "PATH=/a", "DBUS_SESSION_BUS_ADDRESS=bus", "SSH_AUTH_SOCK=/agent", "Z=2"},
24+ },
25+ {
26+ name: "ordinary variables keep creation values",
27+ current: []string{"SHELL=/usr/bin/fish", "PATH=/a", "HOME=/home/user"},
28+ client: []string{"SHELL=/bin/bash", "PATH=/b", "NEW=1"},
29+ want: []string{"SHELL=/usr/bin/fish", "PATH=/a", "HOME=/home/user"},
30+ },
31+ {
32+ name: "graphical attach adds the display group to a console session",
33+ current: []string{"SHELL=/usr/bin/fish", "XDG_RUNTIME_DIR=/run/user/1000"},
34+ client: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "XDG_RUNTIME_DIR=/run/user/1000", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"},
35+ want: []string{"SHELL=/usr/bin/fish", "XDG_RUNTIME_DIR=/run/user/1000", "WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"},
36+ },
37+ {
38+ name: "attach without a display leaves the display group untouched",
39+ current: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "XDG_SESSION_TYPE=wayland", "KEEP=1"},
40+ client: []string{"XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=7", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
41+ want: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "XDG_SESSION_TYPE=wayland", "KEEP=1", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
42+ },
43+ {
44+ name: "ssh with x forwarding leaves the local display group untouched",
45+ current: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland", "XDG_SESSION_ID=2"},
46+ client: []string{"DISPLAY=localhost:10.0", "XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=9", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22", "SSH_AUTH_SOCK=/tmp/fwd-agent"},
47+ want: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland", "XDG_SESSION_ID=2", "SSH_AUTH_SOCK=/tmp/fwd-agent", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
48+ },
49+ {
50+ name: "second compositor on another console replaces the whole display group",
51+ current: []string{"KEEP=1", "WAYLAND_DISPLAY=wayland-0", "DISPLAY=:0", "XDG_CURRENT_DESKTOP=first", "XDG_SESSION_ID=2"},
52+ client: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_CURRENT_DESKTOP=second", "XDG_SESSION_ID=3"},
53+ want: []string{"KEEP=1", "WAYLAND_DISPLAY=wayland-1", "XDG_CURRENT_DESKTOP=second", "XDG_SESSION_ID=3"},
54+ },
55+ {
56+ name: "x11-only desktop removes the stale wayland socket",
57+ current: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland"},
58+ client: []string{"DISPLAY=:1", "XAUTHORITY=/tmp/xauth", "XDG_SESSION_TYPE=x11"},
59+ want: []string{"XDG_SESSION_TYPE=x11", "DISPLAY=:1", "XAUTHORITY=/tmp/xauth"},
60+ },
61+ {
62+ name: "user bus variables follow a client that has them",
63+ current: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/old/bus", "XDG_RUNTIME_DIR=/old"},
64+ client: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000"},
65+ want: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000"},
66+ },
67+ {
68+ name: "user bus variables are kept by an attach without them",
69+ current: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000"},
70+ client: []string{"WAYLAND_DISPLAY=wayland-1"},
71+ want: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000", "WAYLAND_DISPLAY=wayland-1"},
72+ },
73+ {
74+ name: "login variables absent from the client are removed",
75+ current: []string{"SSH_AUTH_SOCK=/tmp/stale", "SSH_CONNECTION=old", "SSH_TTY=/dev/pts/1", "KEEP=1"},
76+ client: []string{"WAYLAND_DISPLAY=wayland-1"},
77+ want: []string{"KEEP=1", "WAYLAND_DISPLAY=wayland-1"},
78+ },
79+ {
80+ name: "agent pid follows the agent socket",
81+ current: []string{"SSH_AUTH_SOCK=/tmp/old", "SSH_AGENT_PID=11"},
82+ client: []string{"SSH_AUTH_SOCK=/tmp/new"},
83+ want: []string{"SSH_AUTH_SOCK=/tmp/new"},
84+ },
85+ {
86+ name: "duplicate current entries collapse in place and the first client value wins",
87+ current: []string{"WAYLAND_DISPLAY=a", "WAYLAND_DISPLAY=b", "PAIR=a=b"},
88+ client: []string{"WAYLAND_DISPLAY=c", "WAYLAND_DISPLAY=d"},
89+ want: []string{"WAYLAND_DISPLAY=c", "PAIR=a=b"},
90+ },
91+ {
92+ name: "an empty display value is not a graphical client",
93+ current: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0"},
94+ client: []string{"DISPLAY=", "WAYLAND_DISPLAY="},
95+ want: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0"},
96+ },
97+ {
98+ name: "an empty member of a graphical client's group is copied",
99+ current: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0"},
100+ client: []string{"WAYLAND_DISPLAY=wayland-2", "DISPLAY="},
101+ want: []string{"WAYLAND_DISPLAY=wayland-2", "DISPLAY="},
102+ },
103+ }
104+ for _, tt := range tests {
105+ t.Run(tt.name, func(t *testing.T) {
106+ current := append([]string(nil), tt.current...)
107+ got := refreshSessionEnvironment(current, tt.client)
108+ require.Equal(t, tt.want, got)
109+ require.Equal(t, tt.current, current, "the previous snapshot is never mutated")
110+ })
111+ }
112+}
113+
114+func TestSessionEnvironmentExport(t *testing.T) {
115+ tests := []struct {
116+ name string
117+ env []string
118+ asJSON bool
119+ want string
120+ }{
121+ {
122+ name: "text lists set session-bound variables only",
123+ env: []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-1", "SSH_AUTH_SOCK=/run/agent", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/bus"},
124+ want: "WAYLAND_DISPLAY=wayland-1\nDBUS_SESSION_BUS_ADDRESS=unix:path=/run/bus\nSSH_AUTH_SOCK=/run/agent\n",
125+ },
126+ {
127+ name: "json with a display unsets the rest of the display group",
128+ env: []string{"WAYLAND_DISPLAY=wayland-1", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/bus", "SSH_AUTH_SOCK=/run/agent"},
129+ asJSON: true,
130+ want: `{"DBUS_SESSION_BUS_ADDRESS":"unix:path=/run/bus","DISPLAY":null,"SSH_AGENT_PID":null,"SSH_AUTH_SOCK":"/run/agent","SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"WAYLAND_DISPLAY":"wayland-1","XAUTHORITY":null,"XDG_CURRENT_DESKTOP":null,"XDG_SESSION_CLASS":null,"XDG_SESSION_DESKTOP":null,"XDG_SESSION_ID":null,"XDG_SESSION_TYPE":null}` + "\n",
131+ },
132+ {
133+ name: "json without a display leaves display variables alone",
134+ env: []string{"SHELL=/bin/sh", "XDG_RUNTIME_DIR=/run/user/1000"},
135+ asJSON: true,
136+ want: `{"SSH_AGENT_PID":null,"SSH_AUTH_SOCK":null,"SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"XDG_RUNTIME_DIR":"/run/user/1000"}` + "\n",
137+ },
138+ {
139+ name: "json with only x11 unsets a stale wayland socket",
140+ env: []string{"DISPLAY=:1"},
141+ asJSON: true,
142+ want: `{"DISPLAY":":1","SSH_AGENT_PID":null,"SSH_AUTH_SOCK":null,"SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"WAYLAND_DISPLAY":null,"XAUTHORITY":null,"XDG_CURRENT_DESKTOP":null,"XDG_SESSION_CLASS":null,"XDG_SESSION_DESKTOP":null,"XDG_SESSION_ID":null,"XDG_SESSION_TYPE":null}` + "\n",
143+ },
144+ {
145+ name: "empty session exports nothing as text",
146+ env: []string{"SHELL=/bin/sh"},
147+ want: "",
148+ },
149+ }
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ got, err := sessionEnvironmentExport(tt.env, tt.asJSON)
153+ require.NoError(t, err)
154+ require.Equal(t, tt.want, got)
155+ })
156+ }
157+}
158+
159+func TestAdoptClientEnvironmentLocked(t *testing.T) {
160+ tests := []struct {
161+ name string
162+ env []string
163+ provisional bool
164+ client []string
165+ want []string
166+ wantProvisional bool
167+ }{
168+ {
169+ name: "provisional daemon environment is replaced wholesale once",
170+ env: []string{"SHELL=/bin/sh", "PATH=/daemon"},
171+ provisional: true,
172+ client: []string{"SHELL=/usr/bin/fish", "PATH=/client", "WAYLAND_DISPLAY=wayland-1"},
173+ want: []string{"SHELL=/usr/bin/fish", "PATH=/client", "WAYLAND_DISPLAY=wayland-1"},
174+ },
175+ {
176+ name: "provisional session keeps its display for an ssh client with x forwarding",
177+ env: []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland"},
178+ provisional: true,
179+ client: []string{"PATH=/ssh", "DISPLAY=localhost:10.0", "XDG_SESSION_TYPE=tty", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
180+ want: []string{"PATH=/ssh", "XDG_SESSION_TYPE=wayland", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22", "WAYLAND_DISPLAY=wayland-1"},
181+ },
182+ {
183+ name: "provisional session keeps its display for a console client",
184+ env: []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1"},
185+ provisional: true,
186+ client: []string{"PATH=/console"},
187+ want: []string{"PATH=/console", "WAYLAND_DISPLAY=wayland-1"},
188+ },
189+ {
190+ name: "provisional session without a display keeps the console client's session variables",
191+ env: []string{"PATH=/daemon"},
192+ provisional: true,
193+ client: []string{"PATH=/console", "XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=4"},
194+ want: []string{"PATH=/console", "XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=4"},
195+ },
196+ {
197+ name: "client environment refreshes only session-bound variables",
198+ env: []string{"SHELL=/usr/bin/fish", "PATH=/first"},
199+ client: []string{"WAYLAND_DISPLAY=wayland-1", "SHELL=/bin/bash", "PATH=/second"},
200+ want: []string{"SHELL=/usr/bin/fish", "PATH=/first", "WAYLAND_DISPLAY=wayland-1"},
201+ },
202+ }
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ client := append([]string(nil), tt.client...)
206+ sess := &session{env: tt.env, envProvisional: tt.provisional}
207+ sess.adoptClientEnvironmentLocked(client)
208+ require.Equal(t, tt.want, sess.env)
209+ require.Equal(t, tt.wantProvisional, sess.envProvisional)
210+ // client[0] is always copied (provisional) or session-bound
211+ // (refresh), so mutating it proves the session owns its entries.
212+ client[0] = "WAYLAND_DISPLAY=mutated"
213+ require.NotContains(t, sess.env, "WAYLAND_DISPLAY=mutated", "the session never aliases the client slice")
214+ })
215+ }
216+}
217+
218+func TestApplyTargetStateRefreshesTargetEnvironment(t *testing.T) {
219+ // The source session last saw a desktop client; the switching attachment
220+ // is a separate SSH client. Only the switching attachment's own
221+ // environment may reach the target.
222+ sourceEnv := []string{"SHELL=/bin/bash", "PATH=/source", "WAYLAND_DISPLAY=wayland-desktop", "SSH_AUTH_SOCK=/run/desktop-agent"}
223+ sshEnv := []string{"SHELL=/bin/zsh", "PATH=/ssh", "SSH_AUTH_SOCK=/tmp/ssh-agent", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"}
224+ targetEnv := []string{"SHELL=/usr/bin/fish", "PATH=/target", "WAYLAND_DISPLAY=wayland-old"}
225+ tests := []struct {
226+ name string
227+ preserve bool
228+ provisional bool
229+ clientEnv []string
230+ want []string
231+ }{
232+ {
233+ name: "handoff refreshes from the switching attachment, not the source session",
234+ clientEnv: sshEnv,
235+ want: []string{"SHELL=/usr/bin/fish", "PATH=/target", "WAYLAND_DISPLAY=wayland-old", "SSH_AUTH_SOCK=/tmp/ssh-agent", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
236+ },
237+ {
238+ name: "ssh handoff into a restored session adopts it but keeps the local display",
239+ provisional: true,
240+ clientEnv: sshEnv,
241+ want: append(append([]string(nil), sshEnv...), "WAYLAND_DISPLAY=wayland-old"),
242+ },
243+ {
244+ name: "daemon-owned attachment never changes the target",
245+ want: targetEnv,
246+ },
247+ {
248+ name: "daemon-owned attachment keeps a restored target provisional",
249+ provisional: true,
250+ want: targetEnv,
251+ },
252+ {
253+ name: "a preserved attachment leaves the target untouched",
254+ preserve: true,
255+ clientEnv: sshEnv,
256+ want: targetEnv,
257+ },
258+ }
259+ for _, tt := range tests {
260+ t.Run(tt.name, func(t *testing.T) {
261+ source := &session{env: copyEnvironment(sourceEnv)}
262+ target := &session{env: copyEnvironment(targetEnv), envProvisional: tt.provisional}
263+ next := &attachedClient{}
264+ next.setClientEnvironment(tt.clientEnv)
265+ d := &Daemon{}
266+ d.applyTargetStateLocked(&attachmentPublication{
267+ req: attachmentTransitionRequest{target: target, next: next, preserveAttachment: tt.preserve},
268+ source: source,
269+ })
270+ require.Equal(t, tt.want, target.env)
271+ require.Equal(t, tt.provisional && (tt.clientEnv == nil || tt.preserve), target.envProvisional)
272+ require.Equal(t, sourceEnv, source.env, "the source session is never read or changed")
273+ })
274+ }
275+}
276+
277+func TestAttachedClientEnvironment(t *testing.T) {
278+ tests := []struct {
279+ name string
280+ hello protocol.Hello
281+ want []string
282+ wantOK bool
283+ }{
284+ {
285+ name: "client-owned hello is recorded",
286+ hello: protocol.Hello{Env: []string{"A=1"}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned},
287+ want: []string{"A=1"},
288+ wantOK: true,
289+ },
290+ {
291+ name: "client-owned empty hello still owns an empty environment",
292+ hello: protocol.Hello{EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned},
293+ want: []string{},
294+ wantOK: true,
295+ },
296+ {
297+ name: "daemon-owned hello records nothing",
298+ hello: protocol.Hello{Env: []string{"A=1"}, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned},
299+ },
300+ }
301+ for _, tt := range tests {
302+ t.Run(tt.name, func(t *testing.T) {
303+ hello := tt.hello
304+ hello.Env = append([]string(nil), tt.hello.Env...)
305+ ac := &attachedClient{}
306+ ac.setClientEnvironment(helloClientEnvironment(hello))
307+ got, ok := ac.clientEnvironment()
308+ require.Equal(t, tt.wantOK, ok)
309+ require.Equal(t, tt.want, got)
310+ if ok && len(hello.Env) > 0 {
311+ hello.Env[0] = "MUTATED=1"
312+ again, _ := ac.clientEnvironment()
313+ require.Equal(t, tt.want, again, "the attachment owns a private copy")
314+ }
315+ })
316+ }
317+}
318+
319+func TestNewRestoredSessionEnvironmentIsProvisional(t *testing.T) {
320+ d := newTestDaemon(t, nil, stubClock{})
321+ d.baseEnv = []string{"SHELL=/bin/sh", "PATH=/daemon"}
322+ sess := d.newRestoredSession(snapcodec.Session{Name: "work"}, d.serveCtx, func() {}, nil)
323+ require.Equal(t, d.baseEnv, sess.env)
324+ require.True(t, sess.envProvisional, "the first client-owned attach must replace the daemon's environment")
325+}
326+
327+// TestFailedAttachTransitionLeavesSessionEnvironment proves a real attach
328+// applies the environment inside the publication: when the transition fails
329+// after the attachment and its client environment are prepared, the session
330+// environment and its provisional state stay untouched.
331+func TestFailedAttachTransitionLeavesSessionEnvironment(t *testing.T) {
332+ d := newTestDaemon(t, nil, stubClock{})
333+ sess := addControlSession(d, "work", "tab-1", "pane-1")
334+ sess.ephemeral = false
335+ sess.incarnation = newTestLifecycle(t)
336+ sess.mu.Lock()
337+ sess.env = []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1"}
338+ sess.envProvisional = true
339+ sess.mu.Unlock()
340+ // Fail the transition after finishAttach prepared the attachment, where
341+ // an eager adopt would already have rewritten the session.
342+ d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) {
343+ d.mu.Lock()
344+ d.closing = true
345+ d.mu.Unlock()
346+ }
347+
348+ target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
349+ env := []string{"PATH=/client", "WAYLAND_DISPLAY=wayland-2"}
350+ hello := protocol.Hello{
351+ Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, Size: defaultSize,
352+ ExactTarget: &target, Env: env, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
353+ }
354+ admission := ports.SessionAdmission{
355+ Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
356+ Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Target: target, Env: env,
357+ }
358+ _, _, err := d.route(hello, &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: admission, present: true})
359+ require.Error(t, err)
360+
361+ sess.mu.Lock()
362+ defer sess.mu.Unlock()
363+ require.Equal(t, []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1"}, sess.env)
364+ require.True(t, sess.envProvisional)
365+}
366+
367+func TestEnvironmentSeedCarriesProvisionalState(t *testing.T) {
368+ tests := []struct {
369+ name string
370+ provisional bool
371+ }{
372+ {name: "client-owned source seeds a client-owned session"},
373+ {name: "provisional source seeds a provisional session", provisional: true},
374+ }
375+ for _, tt := range tests {
376+ t.Run(tt.name, func(t *testing.T) {
377+ factory := &controlPTYFactory{}
378+ d := newTestDaemon(t, factory, stubClock{})
379+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
380+ source := addControlSession(d, "work", "t_work", "p_work")
381+ source.mu.Lock()
382+ source.env = []string{"PATH=/source"}
383+ source.envProvisional = tt.provisional
384+ source.mu.Unlock()
385+
386+ result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"seeded"}, TargetSession: "work"})
387+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
388+ d.mu.Lock()
389+ seeded := d.findByNameLocked("seeded")
390+ d.mu.Unlock()
391+ require.NotNil(t, seeded)
392+ seeded.mu.Lock()
393+ defer seeded.mu.Unlock()
394+ require.Equal(t, []string{"PATH=/source"}, seeded.env)
395+ require.Equal(t, tt.provisional, seeded.envProvisional)
396+ })
397+ }
398+}
@@ -0,0 +1,398 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/bnema/vev/internal/protocol"
8+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestRefreshSessionEnvironment(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ current []string
16+ client []string
17+ want []string
18+ }{
19+ {
20+ name: "refreshing from the creating environment keeps it byte-identical",
21+ current: []string{"A=1", "WAYLAND_DISPLAY=wayland-1", "PATH=/a", "DBUS_SESSION_BUS_ADDRESS=bus", "SSH_AUTH_SOCK=/agent", "Z=2"},
22+ client: []string{"A=1", "WAYLAND_DISPLAY=wayland-1", "PATH=/a", "DBUS_SESSION_BUS_ADDRESS=bus", "SSH_AUTH_SOCK=/agent", "Z=2"},
23+ want: []string{"A=1", "WAYLAND_DISPLAY=wayland-1", "PATH=/a", "DBUS_SESSION_BUS_ADDRESS=bus", "SSH_AUTH_SOCK=/agent", "Z=2"},
24+ },
25+ {
26+ name: "ordinary variables keep creation values",
27+ current: []string{"SHELL=/usr/bin/fish", "PATH=/a", "HOME=/home/user"},
28+ client: []string{"SHELL=/bin/bash", "PATH=/b", "NEW=1"},
29+ want: []string{"SHELL=/usr/bin/fish", "PATH=/a", "HOME=/home/user"},
30+ },
31+ {
32+ name: "graphical attach adds the display group to a console session",
33+ current: []string{"SHELL=/usr/bin/fish", "XDG_RUNTIME_DIR=/run/user/1000"},
34+ client: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "XDG_RUNTIME_DIR=/run/user/1000", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"},
35+ want: []string{"SHELL=/usr/bin/fish", "XDG_RUNTIME_DIR=/run/user/1000", "WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus"},
36+ },
37+ {
38+ name: "attach without a display leaves the display group untouched",
39+ current: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "XDG_SESSION_TYPE=wayland", "KEEP=1"},
40+ client: []string{"XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=7", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
41+ want: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0", "XDG_SESSION_TYPE=wayland", "KEEP=1", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
42+ },
43+ {
44+ name: "ssh with x forwarding leaves the local display group untouched",
45+ current: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland", "XDG_SESSION_ID=2"},
46+ client: []string{"DISPLAY=localhost:10.0", "XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=9", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22", "SSH_AUTH_SOCK=/tmp/fwd-agent"},
47+ want: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland", "XDG_SESSION_ID=2", "SSH_AUTH_SOCK=/tmp/fwd-agent", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
48+ },
49+ {
50+ name: "second compositor on another console replaces the whole display group",
51+ current: []string{"KEEP=1", "WAYLAND_DISPLAY=wayland-0", "DISPLAY=:0", "XDG_CURRENT_DESKTOP=first", "XDG_SESSION_ID=2"},
52+ client: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_CURRENT_DESKTOP=second", "XDG_SESSION_ID=3"},
53+ want: []string{"KEEP=1", "WAYLAND_DISPLAY=wayland-1", "XDG_CURRENT_DESKTOP=second", "XDG_SESSION_ID=3"},
54+ },
55+ {
56+ name: "x11-only desktop removes the stale wayland socket",
57+ current: []string{"WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland"},
58+ client: []string{"DISPLAY=:1", "XAUTHORITY=/tmp/xauth", "XDG_SESSION_TYPE=x11"},
59+ want: []string{"XDG_SESSION_TYPE=x11", "DISPLAY=:1", "XAUTHORITY=/tmp/xauth"},
60+ },
61+ {
62+ name: "user bus variables follow a client that has them",
63+ current: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/old/bus", "XDG_RUNTIME_DIR=/old"},
64+ client: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000"},
65+ want: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000"},
66+ },
67+ {
68+ name: "user bus variables are kept by an attach without them",
69+ current: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000"},
70+ client: []string{"WAYLAND_DISPLAY=wayland-1"},
71+ want: []string{"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_RUNTIME_DIR=/run/user/1000", "WAYLAND_DISPLAY=wayland-1"},
72+ },
73+ {
74+ name: "login variables absent from the client are removed",
75+ current: []string{"SSH_AUTH_SOCK=/tmp/stale", "SSH_CONNECTION=old", "SSH_TTY=/dev/pts/1", "KEEP=1"},
76+ client: []string{"WAYLAND_DISPLAY=wayland-1"},
77+ want: []string{"KEEP=1", "WAYLAND_DISPLAY=wayland-1"},
78+ },
79+ {
80+ name: "agent pid follows the agent socket",
81+ current: []string{"SSH_AUTH_SOCK=/tmp/old", "SSH_AGENT_PID=11"},
82+ client: []string{"SSH_AUTH_SOCK=/tmp/new"},
83+ want: []string{"SSH_AUTH_SOCK=/tmp/new"},
84+ },
85+ {
86+ name: "duplicate current entries collapse in place and the first client value wins",
87+ current: []string{"WAYLAND_DISPLAY=a", "WAYLAND_DISPLAY=b", "PAIR=a=b"},
88+ client: []string{"WAYLAND_DISPLAY=c", "WAYLAND_DISPLAY=d"},
89+ want: []string{"WAYLAND_DISPLAY=c", "PAIR=a=b"},
90+ },
91+ {
92+ name: "an empty display value is not a graphical client",
93+ current: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0"},
94+ client: []string{"DISPLAY=", "WAYLAND_DISPLAY="},
95+ want: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0"},
96+ },
97+ {
98+ name: "an empty member of a graphical client's group is copied",
99+ current: []string{"WAYLAND_DISPLAY=wayland-1", "DISPLAY=:0"},
100+ client: []string{"WAYLAND_DISPLAY=wayland-2", "DISPLAY="},
101+ want: []string{"WAYLAND_DISPLAY=wayland-2", "DISPLAY="},
102+ },
103+ }
104+ for _, tt := range tests {
105+ t.Run(tt.name, func(t *testing.T) {
106+ current := append([]string(nil), tt.current...)
107+ got := refreshSessionEnvironment(current, tt.client)
108+ require.Equal(t, tt.want, got)
109+ require.Equal(t, tt.current, current, "the previous snapshot is never mutated")
110+ })
111+ }
112+}
113+
114+func TestSessionEnvironmentExport(t *testing.T) {
115+ tests := []struct {
116+ name string
117+ env []string
118+ asJSON bool
119+ want string
120+ }{
121+ {
122+ name: "text lists set session-bound variables only",
123+ env: []string{"SHELL=/usr/bin/fish", "WAYLAND_DISPLAY=wayland-1", "SSH_AUTH_SOCK=/run/agent", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/bus"},
124+ want: "WAYLAND_DISPLAY=wayland-1\nDBUS_SESSION_BUS_ADDRESS=unix:path=/run/bus\nSSH_AUTH_SOCK=/run/agent\n",
125+ },
126+ {
127+ name: "json with a display unsets the rest of the display group",
128+ env: []string{"WAYLAND_DISPLAY=wayland-1", "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/bus", "SSH_AUTH_SOCK=/run/agent"},
129+ asJSON: true,
130+ want: `{"DBUS_SESSION_BUS_ADDRESS":"unix:path=/run/bus","DISPLAY":null,"SSH_AGENT_PID":null,"SSH_AUTH_SOCK":"/run/agent","SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"WAYLAND_DISPLAY":"wayland-1","XAUTHORITY":null,"XDG_CURRENT_DESKTOP":null,"XDG_SESSION_CLASS":null,"XDG_SESSION_DESKTOP":null,"XDG_SESSION_ID":null,"XDG_SESSION_TYPE":null}` + "\n",
131+ },
132+ {
133+ name: "json without a display leaves display variables alone",
134+ env: []string{"SHELL=/bin/sh", "XDG_RUNTIME_DIR=/run/user/1000"},
135+ asJSON: true,
136+ want: `{"SSH_AGENT_PID":null,"SSH_AUTH_SOCK":null,"SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"XDG_RUNTIME_DIR":"/run/user/1000"}` + "\n",
137+ },
138+ {
139+ name: "json with only x11 unsets a stale wayland socket",
140+ env: []string{"DISPLAY=:1"},
141+ asJSON: true,
142+ want: `{"DISPLAY":":1","SSH_AGENT_PID":null,"SSH_AUTH_SOCK":null,"SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"WAYLAND_DISPLAY":null,"XAUTHORITY":null,"XDG_CURRENT_DESKTOP":null,"XDG_SESSION_CLASS":null,"XDG_SESSION_DESKTOP":null,"XDG_SESSION_ID":null,"XDG_SESSION_TYPE":null}` + "\n",
143+ },
144+ {
145+ name: "empty session exports nothing as text",
146+ env: []string{"SHELL=/bin/sh"},
147+ want: "",
148+ },
149+ }
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ got, err := sessionEnvironmentExport(tt.env, tt.asJSON)
153+ require.NoError(t, err)
154+ require.Equal(t, tt.want, got)
155+ })
156+ }
157+}
158+
159+func TestAdoptClientEnvironmentLocked(t *testing.T) {
160+ tests := []struct {
161+ name string
162+ env []string
163+ provisional bool
164+ client []string
165+ want []string
166+ wantProvisional bool
167+ }{
168+ {
169+ name: "provisional daemon environment is replaced wholesale once",
170+ env: []string{"SHELL=/bin/sh", "PATH=/daemon"},
171+ provisional: true,
172+ client: []string{"SHELL=/usr/bin/fish", "PATH=/client", "WAYLAND_DISPLAY=wayland-1"},
173+ want: []string{"SHELL=/usr/bin/fish", "PATH=/client", "WAYLAND_DISPLAY=wayland-1"},
174+ },
175+ {
176+ name: "provisional session keeps its display for an ssh client with x forwarding",
177+ env: []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1", "XDG_SESSION_TYPE=wayland"},
178+ provisional: true,
179+ client: []string{"PATH=/ssh", "DISPLAY=localhost:10.0", "XDG_SESSION_TYPE=tty", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
180+ want: []string{"PATH=/ssh", "XDG_SESSION_TYPE=wayland", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22", "WAYLAND_DISPLAY=wayland-1"},
181+ },
182+ {
183+ name: "provisional session keeps its display for a console client",
184+ env: []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1"},
185+ provisional: true,
186+ client: []string{"PATH=/console"},
187+ want: []string{"PATH=/console", "WAYLAND_DISPLAY=wayland-1"},
188+ },
189+ {
190+ name: "provisional session without a display keeps the console client's session variables",
191+ env: []string{"PATH=/daemon"},
192+ provisional: true,
193+ client: []string{"PATH=/console", "XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=4"},
194+ want: []string{"PATH=/console", "XDG_SESSION_TYPE=tty", "XDG_SESSION_ID=4"},
195+ },
196+ {
197+ name: "client environment refreshes only session-bound variables",
198+ env: []string{"SHELL=/usr/bin/fish", "PATH=/first"},
199+ client: []string{"WAYLAND_DISPLAY=wayland-1", "SHELL=/bin/bash", "PATH=/second"},
200+ want: []string{"SHELL=/usr/bin/fish", "PATH=/first", "WAYLAND_DISPLAY=wayland-1"},
201+ },
202+ }
203+ for _, tt := range tests {
204+ t.Run(tt.name, func(t *testing.T) {
205+ client := append([]string(nil), tt.client...)
206+ sess := &session{env: tt.env, envProvisional: tt.provisional}
207+ sess.adoptClientEnvironmentLocked(client)
208+ require.Equal(t, tt.want, sess.env)
209+ require.Equal(t, tt.wantProvisional, sess.envProvisional)
210+ // client[0] is always copied (provisional) or session-bound
211+ // (refresh), so mutating it proves the session owns its entries.
212+ client[0] = "WAYLAND_DISPLAY=mutated"
213+ require.NotContains(t, sess.env, "WAYLAND_DISPLAY=mutated", "the session never aliases the client slice")
214+ })
215+ }
216+}
217+
218+func TestApplyTargetStateRefreshesTargetEnvironment(t *testing.T) {
219+ // The source session last saw a desktop client; the switching attachment
220+ // is a separate SSH client. Only the switching attachment's own
221+ // environment may reach the target.
222+ sourceEnv := []string{"SHELL=/bin/bash", "PATH=/source", "WAYLAND_DISPLAY=wayland-desktop", "SSH_AUTH_SOCK=/run/desktop-agent"}
223+ sshEnv := []string{"SHELL=/bin/zsh", "PATH=/ssh", "SSH_AUTH_SOCK=/tmp/ssh-agent", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"}
224+ targetEnv := []string{"SHELL=/usr/bin/fish", "PATH=/target", "WAYLAND_DISPLAY=wayland-old"}
225+ tests := []struct {
226+ name string
227+ preserve bool
228+ provisional bool
229+ clientEnv []string
230+ want []string
231+ }{
232+ {
233+ name: "handoff refreshes from the switching attachment, not the source session",
234+ clientEnv: sshEnv,
235+ want: []string{"SHELL=/usr/bin/fish", "PATH=/target", "WAYLAND_DISPLAY=wayland-old", "SSH_AUTH_SOCK=/tmp/ssh-agent", "SSH_CONNECTION=10.0.0.2 1 10.0.0.1 22"},
236+ },
237+ {
238+ name: "ssh handoff into a restored session adopts it but keeps the local display",
239+ provisional: true,
240+ clientEnv: sshEnv,
241+ want: append(append([]string(nil), sshEnv...), "WAYLAND_DISPLAY=wayland-old"),
242+ },
243+ {
244+ name: "daemon-owned attachment never changes the target",
245+ want: targetEnv,
246+ },
247+ {
248+ name: "daemon-owned attachment keeps a restored target provisional",
249+ provisional: true,
250+ want: targetEnv,
251+ },
252+ {
253+ name: "a preserved attachment leaves the target untouched",
254+ preserve: true,
255+ clientEnv: sshEnv,
256+ want: targetEnv,
257+ },
258+ }
259+ for _, tt := range tests {
260+ t.Run(tt.name, func(t *testing.T) {
261+ source := &session{env: copyEnvironment(sourceEnv)}
262+ target := &session{env: copyEnvironment(targetEnv), envProvisional: tt.provisional}
263+ next := &attachedClient{}
264+ next.setClientEnvironment(tt.clientEnv)
265+ d := &Daemon{}
266+ d.applyTargetStateLocked(&attachmentPublication{
267+ req: attachmentTransitionRequest{target: target, next: next, preserveAttachment: tt.preserve},
268+ source: source,
269+ })
270+ require.Equal(t, tt.want, target.env)
271+ require.Equal(t, tt.provisional && (tt.clientEnv == nil || tt.preserve), target.envProvisional)
272+ require.Equal(t, sourceEnv, source.env, "the source session is never read or changed")
273+ })
274+ }
275+}
276+
277+func TestAttachedClientEnvironment(t *testing.T) {
278+ tests := []struct {
279+ name string
280+ hello protocol.Hello
281+ want []string
282+ wantOK bool
283+ }{
284+ {
285+ name: "client-owned hello is recorded",
286+ hello: protocol.Hello{Env: []string{"A=1"}, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned},
287+ want: []string{"A=1"},
288+ wantOK: true,
289+ },
290+ {
291+ name: "client-owned empty hello still owns an empty environment",
292+ hello: protocol.Hello{EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned},
293+ want: []string{},
294+ wantOK: true,
295+ },
296+ {
297+ name: "daemon-owned hello records nothing",
298+ hello: protocol.Hello{Env: []string{"A=1"}, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned},
299+ },
300+ }
301+ for _, tt := range tests {
302+ t.Run(tt.name, func(t *testing.T) {
303+ hello := tt.hello
304+ hello.Env = append([]string(nil), tt.hello.Env...)
305+ ac := &attachedClient{}
306+ ac.setClientEnvironment(helloClientEnvironment(hello))
307+ got, ok := ac.clientEnvironment()
308+ require.Equal(t, tt.wantOK, ok)
309+ require.Equal(t, tt.want, got)
310+ if ok && len(hello.Env) > 0 {
311+ hello.Env[0] = "MUTATED=1"
312+ again, _ := ac.clientEnvironment()
313+ require.Equal(t, tt.want, again, "the attachment owns a private copy")
314+ }
315+ })
316+ }
317+}
318+
319+func TestNewRestoredSessionEnvironmentIsProvisional(t *testing.T) {
320+ d := newTestDaemon(t, nil, stubClock{})
321+ d.baseEnv = []string{"SHELL=/bin/sh", "PATH=/daemon"}
322+ sess := d.newRestoredSession(snapcodec.Session{Name: "work"}, d.serveCtx, func() {}, nil)
323+ require.Equal(t, d.baseEnv, sess.env)
324+ require.True(t, sess.envProvisional, "the first client-owned attach must replace the daemon's environment")
325+}
326+
327+// TestFailedAttachTransitionLeavesSessionEnvironment proves a real attach
328+// applies the environment inside the publication: when the transition fails
329+// after the attachment and its client environment are prepared, the session
330+// environment and its provisional state stay untouched.
331+func TestFailedAttachTransitionLeavesSessionEnvironment(t *testing.T) {
332+ d := newTestDaemon(t, nil, stubClock{})
333+ sess := addControlSession(d, "work", "tab-1", "pane-1")
334+ sess.ephemeral = false
335+ sess.incarnation = newTestLifecycle(t)
336+ sess.mu.Lock()
337+ sess.env = []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1"}
338+ sess.envProvisional = true
339+ sess.mu.Unlock()
340+ // Fail the transition after finishAttach prepared the attachment, where
341+ // an eager adopt would already have rewritten the session.
342+ d.afterAttachmentEffectParticipantsSnapshotted = func(string, []*attachedClient) {
343+ d.mu.Lock()
344+ d.closing = true
345+ d.mu.Unlock()
346+ }
347+
348+ target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name}
349+ env := []string{"PATH=/client", "WAYLAND_DISPLAY=wayland-2"}
350+ hello := protocol.Hello{
351+ Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, Size: defaultSize,
352+ ExactTarget: &target, Env: env, EnvironmentPolicy: protocol.EnvironmentPolicyClientOwned,
353+ }
354+ admission := ports.SessionAdmission{
355+ Origin: ports.SessionOriginLocal, Policy: admissionTestPolicy(ports.SessionOriginLocal),
356+ Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Target: target, Env: env,
357+ }
358+ _, _, err := d.route(hello, &admittedTransport{ServerConnection: &closeTrackingTransport{}, admission: admission, present: true})
359+ require.Error(t, err)
360+
361+ sess.mu.Lock()
362+ defer sess.mu.Unlock()
363+ require.Equal(t, []string{"PATH=/daemon", "WAYLAND_DISPLAY=wayland-1"}, sess.env)
364+ require.True(t, sess.envProvisional)
365+}
366+
367+func TestEnvironmentSeedCarriesProvisionalState(t *testing.T) {
368+ tests := []struct {
369+ name string
370+ provisional bool
371+ }{
372+ {name: "client-owned source seeds a client-owned session"},
373+ {name: "provisional source seeds a provisional session", provisional: true},
374+ }
375+ for _, tt := range tests {
376+ t.Run(tt.name, func(t *testing.T) {
377+ factory := &controlPTYFactory{}
378+ d := newTestDaemon(t, factory, stubClock{})
379+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
380+ source := addControlSession(d, "work", "t_work", "p_work")
381+ source.mu.Lock()
382+ source.env = []string{"PATH=/source"}
383+ source.envProvisional = tt.provisional
384+ source.mu.Unlock()
385+
386+ result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"seeded"}, TargetSession: "work"})
387+ require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
388+ d.mu.Lock()
389+ seeded := d.findByNameLocked("seeded")
390+ d.mu.Unlock()
391+ require.NotNil(t, seeded)
392+ seeded.mu.Lock()
393+ defer seeded.mu.Unlock()
394+ require.Equal(t, []string{"PATH=/source"}, seeded.env)
395+ require.Equal(t, tt.provisional, seeded.envProvisional)
396+ })
397+ }
398+}
M internal/usecase/daemon/session_test.go +10 -7
@@ -1746,7 +1746,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
17461746 d.sessWg.Wait()
17471747 }
17481748
1749-func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
1749+func TestAttachEnvironmentRefreshesOnlySessionBoundFuturePTYInputs(t *testing.T) {
17501750 sz := domain.Size{Cols: 80, Rows: 24}
17511751 p1, release1 := newBlockingPTY(t)
17521752 defer release1()
@@ -1777,10 +1777,10 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
17771777 tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()
17781778 tr2.EXPECT().Close().Return(nil).Maybe()
17791779
1780- first := []string{"SECRET=first", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b"}
1780+ first := []string{"SECRET=first", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "WAYLAND_DISPLAY=wayland-1", "SSH_AUTH_SOCK=/run/first-agent"}
17811781 sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, Env: first}, tr1)
17821782 require.NoError(t, err)
1783- second := []string{"SECRET=second", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b"}
1783+ second := []string{"SECRET=second", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "DISPLAY=:0"}
17841784 _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz, Env: second}, tr2)
17851785 require.NoError(t, err)
17861786 defer func() {
@@ -1789,12 +1789,15 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
17891789 }()
17901790
17911791 require.Equal(t, "/usr/bin/fish", commands[0])
1792- require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
1792+ require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "WAYLAND_DISPLAY=wayland-1", "SSH_AUTH_SOCK=/run/first-agent", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
17931793 require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator")
17941794 require.NoError(t, d.createTab(sess, ac.size))
1795- require.Equal(t, "/bin/bash", commands[1])
1796- require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])
1797- require.Equal(t, "bash", sess.tabs[1].focusedPaneTitle(false), "later tabs use the most recently attached shell")
1795+ // Only session-bound variables follow the attaching client: the X11-only
1796+ // display group replaces the Wayland one, the absent agent socket is
1797+ // removed, and the creator's shell and ordinary variables stay.
1798+ require.Equal(t, "/usr/bin/fish", commands[1])
1799+ require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "DISPLAY=:0", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])
1800+ require.Equal(t, "fish", sess.tabs[1].focusedPaneTitle(false), "later tabs keep the session creator's shell")
17981801 }
17991802
18001803 func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {
@@ -1746,7 +1746,7 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1746 d.sessWg.Wait()1746 d.sessWg.Wait()
1747 }1747 }
1748 1748
1749-func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {1749+func TestAttachEnvironmentRefreshesOnlySessionBoundFuturePTYInputs(t *testing.T) {
1750 sz := domain.Size{Cols: 80, Rows: 24}1750 sz := domain.Size{Cols: 80, Rows: 24}
1751 p1, release1 := newBlockingPTY(t)1751 p1, release1 := newBlockingPTY(t)
1752 defer release1()1752 defer release1()
@@ -1777,10 +1777,10 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
1777 tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()1777 tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe()
1778 tr2.EXPECT().Close().Return(nil).Maybe()1778 tr2.EXPECT().Close().Return(nil).Maybe()
1779 1779
1780- first := []string{"SECRET=first", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b"}1780+ first := []string{"SECRET=first", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "WAYLAND_DISPLAY=wayland-1", "SSH_AUTH_SOCK=/run/first-agent"}
1781 sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, Env: first}, tr1)1781 sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, Env: first}, tr1)
1782 require.NoError(t, err)1782 require.NoError(t, err)
1783- second := []string{"SECRET=second", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b"}1783+ second := []string{"SECRET=second", "TERM=bad", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "DISPLAY=:0"}
1784 _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz, Env: second}, tr2)1784 _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz, Env: second}, tr2)
1785 require.NoError(t, err)1785 require.NoError(t, err)
1786 defer func() {1786 defer func() {
@@ -1789,12 +1789,15 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
1789 }()1789 }()
1790 1790
1791 require.Equal(t, "/usr/bin/fish", commands[0])1791 require.Equal(t, "/usr/bin/fish", commands[0])
1792- require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])1792+ require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "WAYLAND_DISPLAY=wayland-1", "SSH_AUTH_SOCK=/run/first-agent", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
1793 require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator")1793 require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator")
1794 require.NoError(t, d.createTab(sess, ac.size))1794 require.NoError(t, d.createTab(sess, ac.size))
1795- require.Equal(t, "/bin/bash", commands[1])1795+ // Only session-bound variables follow the attaching client: the X11-only
1796- require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])1796+ // display group replaces the Wayland one, the absent agent socket is
1797- require.Equal(t, "bash", sess.tabs[1].focusedPaneTitle(false), "later tabs use the most recently attached shell")1797+ // removed, and the creator's shell and ordinary variables stay.
1798+ require.Equal(t, "/usr/bin/fish", commands[1])
1799+ require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "DISPLAY=:0", "TERM=xterm-256color", "COLORTERM=truecolor", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])
1800+ require.Equal(t, "fish", sess.tabs[1].focusedPaneTitle(false), "later tabs keep the session creator's shell")
1798 }1801 }
1799 1802
1800 func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {1803 func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {
M internal/usecase/daemon/snapshot_restore.go +1 -1
@@ -292,7 +292,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
292292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
293293 // Restored sessions keep ordered tabs; attachment repair deterministically
294294 // selects the first tab instead of restoring an interactive client view.
295- sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
295+ sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, env: copyEnvironment(d.baseEnv), envProvisional: true, snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
296296 // Restored tabs remain private until persistAndRegisterRestoredSession.
297297 // Initialize owners now so registration and reader startup cannot expose an
298298 // ownerless pane.
@@ -292,7 +292,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {292 func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session {
293 // Restored sessions keep ordered tabs; attachment repair deterministically293 // Restored sessions keep ordered tabs; attachment repair deterministically
294 // selects the first tab instead of restoring an interactive client view.294 // selects the first tab instead of restoring an interactive client view.
295- sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, env: copyEnvironment(d.baseEnv), snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}295+ sess := &session{sessionCore: sessionCore{name: snap.Name, createdAt: int64(snap.CreatedAt)}, ctx: sctx, cancel: cancel, tabs: tabs, env: copyEnvironment(d.baseEnv), envProvisional: true, snapshotWake: d.snapshotWake, snapshotChunkCache: newSnapshotChunkCache(snapshotChunkCacheLimit)}
296 // Restored tabs remain private until persistAndRegisterRestoredSession.296 // Restored tabs remain private until persistAndRegisterRestoredSession.
297 // Initialize owners now so registration and reader startup cannot expose an297 // Initialize owners now so registration and reader startup cannot expose an
298 // ownerless pane.298 // ownerless pane.

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