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 Unverified
B5690EEEBB952194The key is unknown
29 files · +1292 -79
M README.md +3 -0
| @@ -114,10 +114,13 @@ Full list: [configuration](docs/configuration.md). | |||
| 114 | 114 | vev cmd split-right | |
| 115 | 115 | vev cmd toast -l warn "build failed" | |
| 116 | 116 | vev cmd list-panes --json | |
| 117 | + | vev cmd env --json | |
| 117 | 118 | ``` | |
| 118 | 119 | ||
| 119 | 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 | 124 | ## Documentation | |
| 122 | 125 | ||
| 123 | 126 | - [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-right | 114 | 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 --json | 116 | 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 | ## Documentation | 124 | ## Documentation | ||
| 122 | 125 | ||||
| 123 | - [Configuration](docs/configuration.md): bindings, palette, theme, overlays, and bar anchors | 126 | - [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 | |||
| 59 | 59 | ||
| 60 | 60 | ## 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 | 90 | - vev always sets `TERM`, `COLORTERM`, `TERM_PROGRAM`, and `VEV`. `SHELL` picks the shell. | |
| 64 | 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 | ||
| @@ -59,7 +59,34 @@ When your outer terminal supports the kitty keyboard protocol (kitty, foot, ghos | |||||
| 59 | 59 | ||||
| 60 | ## Environment | 60 | ## 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 { | |||
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | 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 | 171 | stdout: os.Stdout, | |
| 167 | 172 | getenv: os.Getenv, | |
| 168 | 173 | connect: connectProductionBroker, | |
| @@ -171,7 +176,7 @@ func runCmd(ctx context.Context, invocation cmdInvocation) error { | |||
| 171 | 176 | return ensureDaemonWithLifecycle(ctx, dir, realDial, realSpawn, defaultBackoff) | |
| 172 | 177 | }, | |
| 173 | 178 | clock: clock.New(), | |
| 174 | - | }) | |
| 179 | + | } | |
| 175 | 180 | } | |
| 176 | 181 | ||
| 177 | 182 | 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 | |||
| 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 | 532 | func TestIntegration_AttachEnvironmentRefreshesFuturePTYChildren(t *testing.T) { | |
| 524 | 533 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 525 | 534 | dir, _ := startDaemon(t) | |
| 526 | - | firstShell := shellFixture(t, "first") | |
| 535 | + | firstShell := displayShellFixture(t) | |
| 527 | 536 | secondShell := shellFixture(t, "second") | |
| 528 | 537 | firstEnv := []string{ | |
| 529 | 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 | 557 | require.NoError(t, tr2.SendClient(protocol.Input{Data: []byte("\x1b ")})) | |
| 549 | 558 | awaitText(t, p2, sz, "Commands") | |
| 550 | 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 | 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 | 566 | func TestIntegration_TwoAttachmentsReceiveSharedMutationPTYOutput(t *testing.T) { | |
| 556 | 567 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 557 | 568 | dir, _ := startDaemon(t) | |
| 558 | - | firstShell := shellFixture(t, "first") | |
| 559 | 569 | 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") | |
| 562 | 575 | firstEnv := []string{ | |
| 563 | 576 | "VEV_TEST_ENV=first", "SHELL=" + firstShell, "XDG_RUNTIME_DIR=/run/first", "WAYLAND_DISPLAY=wayland-first", | |
| 564 | 577 | "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 ( | |||
| 58 | 58 | kindHost | |
| 59 | 59 | kindKill | |
| 60 | 60 | kindCmd | |
| 61 | + | kindEnv | |
| 61 | 62 | kindDaemon | |
| 62 | 63 | kindDaemonLauncher | |
| 63 | 64 | kindUIDriver | |
| @@ -90,6 +91,7 @@ type command struct { | |||
| 90 | 91 | killAll bool | |
| 91 | 92 | killSessions bool | |
| 92 | 93 | cmd cmdInvocation | |
| 94 | + | env envInvocation | |
| 93 | 95 | brokerServe brokerServeOptions | |
| 94 | 96 | brokerMux brokerMuxOptions | |
| 95 | 97 | brokerReady brokerReadyOptions | |
| @@ -129,6 +131,7 @@ usage: | |||
| 129 | 131 | vev kill --all stop everything: every vev window, the broker, and the | |
| 130 | 132 | daemon (named sessions come back on the next start) | |
| 131 | 133 | vev cmd <command> run a control command (vev cmd --help) | |
| 134 | + | vev env <fish|sh> print shell code that refreshes desktop variables | |
| 132 | 135 | vev --ui-observe expose passive observation for this interactive client | |
| 133 | 136 | (optional: --ui-socket PATH) | |
| 134 | 137 | vev --ui-control expose observation and input control for this client | |
| @@ -308,6 +311,12 @@ parsedUIFlags: | |||
| 308 | 311 | return parseListArgs(args[1:]) | |
| 309 | 312 | case "host": | |
| 310 | 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 | 320 | case "cmd": | |
| 312 | 321 | invocation, err := parseCmdArgs(args[1:]) | |
| 313 | 322 | if err != nil { | |
| @@ -395,6 +404,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 395 | 404 | return runKill(ctx, cmd.name, cmd.killSessions) | |
| 396 | 405 | case kindCmd: | |
| 397 | 406 | return runCmd(ctx, cmd.cmd) | |
| 407 | + | case kindEnv: | |
| 408 | + | return runEnv(ctx, cmd.env) | |
| 398 | 409 | case kindAttach: | |
| 399 | 410 | return runAttachWithOptions(ctx, cmd.intent, cmd.name, cmd.remoteTarget, interactiveUIOptions{observe: cmd.uiObserve, control: cmd.uiControl, socket: cmd.uiSocket}) | |
| 400 | 411 | default: | |
| @@ -58,6 +58,7 @@ const ( | |||||
| 58 | kindHost | 58 | kindHost | ||
| 59 | kindKill | 59 | kindKill | ||
| 60 | kindCmd | 60 | kindCmd | ||
| 61 | + | kindEnv | |||
| 61 | kindDaemon | 62 | kindDaemon | ||
| 62 | kindDaemonLauncher | 63 | kindDaemonLauncher | ||
| 63 | kindUIDriver | 64 | kindUIDriver | ||
| @@ -90,6 +91,7 @@ type command struct { | |||||
| 90 | killAll bool | 91 | killAll bool | ||
| 91 | killSessions bool | 92 | killSessions bool | ||
| 92 | cmd cmdInvocation | 93 | cmd cmdInvocation | ||
| 94 | + | env envInvocation | |||
| 93 | brokerServe brokerServeOptions | 95 | brokerServe brokerServeOptions | ||
| 94 | brokerMux brokerMuxOptions | 96 | brokerMux brokerMuxOptions | ||
| 95 | brokerReady brokerReadyOptions | 97 | brokerReady brokerReadyOptions | ||
| @@ -129,6 +131,7 @@ usage: | |||||
| 129 | vev kill --all stop everything: every vev window, the broker, and the | 131 | 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 client | 135 | 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 client | 137 | 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 { | |||
| 160 | 160 | ListSessions(json bool) (string, error) | |
| 161 | 161 | ListTabs(json bool) (string, error) | |
| 162 | 162 | ListPanes(json bool) (string, error) | |
| 163 | + | SessionEnvironment(json bool) (string, error) | |
| 163 | 164 | RemoteCatalog(json bool) (string, error) | |
| 164 | 165 | } | |
| 165 | 166 | ||
| @@ -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 { | |||
| 52 | 52 | listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }), | |
| 53 | 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 | 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 | 56 | remoteCatalogCommand(), | |
| 56 | 57 | sessionRecoveryCommand(), | |
| 57 | 58 | } | |
| @@ -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) { | |||
| 145 | 145 | "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab, | |
| 146 | 146 | "move-pane": TargetPane, "move-tab": TargetTab, | |
| 147 | 147 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | |
| 148 | - | "remote-catalog": TargetNone, | |
| 148 | + | "remote-catalog": TargetNone, "env": TargetSession, | |
| 149 | 149 | } | |
| 150 | 150 | notScriptable := []string{ | |
| 151 | 151 | "session-picker", "notifications", "visual-mode", "yank-last-notification", | |
| @@ -174,7 +174,7 @@ func TestPaletteRegistryHidesAPIOnly(t *testing.T) { | |||
| 174 | 174 | if !cmd.PaletteVisible || cmd.Run == nil { | |
| 175 | 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 | 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 | 275 | {name: "toast rejects malformed flags", slug: "toast", args: []string{"-l", "warn"}, wantErr: ErrInvalidArguments}, | |
| 276 | 276 | {name: "query delegates JSON option", slug: "list-panes", opts: ControlOptions{JSON: true}, wantCall: "list-panes:true", wantOutput: "panes"}, | |
| 277 | 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 | 281 | for _, tt := range tests { | |
| 280 | 282 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -348,6 +350,10 @@ func (s *controlSpy) ListPanes(json bool) (string, error) { | |||
| 348 | 350 | _ = s.record("list-panes:" + strconv.FormatBool(json)) | |
| 349 | 351 | 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 | 357 | func (s *controlSpy) RemoteCatalog(json bool) (string, error) { | |
| 352 | 358 | _ = s.record("remote-catalog:" + strconv.FormatBool(json)) | |
| 353 | 359 | 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", nil | 351 | 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, nil | 359 | return s.catalogOutput, nil | ||
M internal/usecase/daemon/attachment_transition.go +9 -10
| @@ -20,16 +20,15 @@ type attachmentLifecycleFence struct { | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | 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 | |
| 33 | 32 | // preserveAttachment commits Attachment-local navigation state without | |
| 34 | 33 | // changing Session membership. The initiating capability remains the exact | |
| 35 | 34 | // 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 *session | 23 | + | source *session |
| 24 | - | target *session | 24 | + | target *session |
| 25 | - | next *attachedClient | 25 | + | next *attachedClient |
| 26 | - | expectedTransport transportSnapshot | 26 | + | expectedTransport transportSnapshot |
| 27 | - | sourceCapability *attachmentCapability | 27 | + | sourceCapability *attachmentCapability |
| 28 | - | sourceEffect *attachmentEffect | 28 | + | sourceEffect *attachmentEffect |
| 29 | - | action string | 29 | + | action string |
| 30 | - | activateTargetTab bool | 30 | + | activateTargetTab bool |
| 31 | - | targetTabIndex int | 31 | + | targetTabIndex int |
| 32 | - | copySourceEnvironment bool | |||
| 33 | // preserveAttachment commits Attachment-local navigation state without | 32 | // preserveAttachment commits Attachment-local navigation state without | ||
| 34 | // changing Session membership. The initiating capability remains the exact | 33 | // 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 | |||
| 173 | 173 | if req.activateTargetTab && req.target != nil { | |
| 174 | 174 | req.target.activateAttachmentViewLocked(req.next, req.targetTabIndex) | |
| 175 | 175 | } | |
| 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 | + | } | |
| 178 | 184 | } | |
| 179 | 185 | return req.preserveAttachment | |
| 180 | 186 | } | |
| @@ -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.preserveAttachment | 185 | 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 | |||
| 118 | 118 | } | |
| 119 | 119 | sess.mu.Lock() | |
| 120 | 120 | 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. | |
| 124 | 124 | env := sess.env | |
| 125 | 125 | attachments := sess.snapshotAttachmentsLocked() | |
| 126 | 126 | 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.name | 120 | ctx.Session = sess.name | ||
| 121 | - | // No copy needed: writers always replace sess.env with a whole fresh | 121 | + | // 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 the | 122 | + | // 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.env | 124 | env := sess.env | ||
| 125 | attachments := sess.snapshotAttachmentsLocked() | 125 | attachments := sess.snapshotAttachmentsLocked() | ||
| 126 | ctx.PaneCWD = sess.cwd | 126 | ctx.PaneCWD = sess.cwd | ||
M internal/usecase/daemon/client.go +8 -0
| @@ -76,6 +76,12 @@ type attachedClient struct { | |||
| 76 | 76 | // clientPID is the local client process from Hello, 0 when remote. It is | |
| 77 | 77 | // atomic because a resume replaces it while bar scripts read it. | |
| 78 | 78 | 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 | 85 | // prepareFailureFallback prevents a direct fallback paint from recursively | |
| 80 | 86 | // reporting the same failed prepare through its notice repaint. It is only | |
| 81 | 87 | // needed while no render coordinator is installed. | |
| @@ -661,6 +667,7 @@ type attachClientOptions struct { | |||
| 661 | 667 | navigationCapabilities protocol.NavigationCapabilities | |
| 662 | 668 | terminalFocus domain.TerminalFocus | |
| 663 | 669 | clientPID uint32 | |
| 670 | + | clientEnv []string | |
| 664 | 671 | } | |
| 665 | 672 | ||
| 666 | 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 | 752 | ac.setTerminalFocus(opts.terminalFocus) | |
| 746 | 753 | ac.clientPID.Store(opts.clientPID) | |
| 754 | + | ac.setClientEnvironment(opts.clientEnv) | |
| 747 | 755 | output.attachment = ac | |
| 748 | 756 | ac.initOverlays() | |
| 749 | 757 | 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 is | 76 | // 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.Uint32 | 78 | 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 recursively | 85 | // prepareFailureFallback prevents a direct fallback paint from recursively | ||
| 80 | // reporting the same failed prepare through its notice repaint. It is only | 86 | // 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.NavigationCapabilities | 667 | navigationCapabilities protocol.NavigationCapabilities | ||
| 662 | terminalFocus domain.TerminalFocus | 668 | terminalFocus domain.TerminalFocus | ||
| 663 | clientPID uint32 | 669 | 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 = ac | 755 | 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 { | |||
| 542 | 542 | return command.ErrInvalidArguments | |
| 543 | 543 | } | |
| 544 | 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 | 547 | e.sess.mu.Unlock() | |
| 547 | 548 | geometry := domain.Geometry{Size: e.sess.fullViewportSize()} | |
| 548 | 549 | if source, ok := e.sess.geometry.sourceSnapshot(e.sess); ok { | |
| @@ -559,7 +560,10 @@ func (e controlExec) CreateSessionNamed(name string) error { | |||
| 559 | 560 | if e.d.nameLiveOrStoppedLocked(name) { | |
| 560 | 561 | 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 | 567 | return err | |
| 564 | 568 | } | |
| 565 | 569 | func (e controlExec) CloseTab() error { | |
| @@ -906,6 +910,15 @@ func (e controlExec) ListTabs(asJSON bool) (string, error) { | |||
| 906 | 910 | 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 | 922 | func (e controlExec) ListPanes(asJSON bool) (string, error) { | |
| 910 | 923 | type row struct { | |
| 911 | 924 | ID string `json:"id"` | |
| @@ -542,7 +542,8 @@ func (e controlExec) CreateSessionNamed(name string) error { | |||||
| 542 | return command.ErrInvalidArguments | 542 | 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 errSessionNameInUse | 561 | 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 err | 567 | 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(), nil | 910 | 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) { | |||
| 736 | 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 | 771 | func TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) { | |
| 740 | 772 | d := newTestDaemon(t, nil, stubClock{}) | |
| 741 | 773 | 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 | |||
| 1477 | 1477 | // otherwise falls back to the session's normal first-tab repair. | |
| 1478 | 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 | 1480 | terminalCapabilities := terminalcap.Detect(h.Env) | |
| 1490 | 1481 | // Kitty graphics are enabled only by the explicit direct-terminal | |
| 1491 | 1482 | // 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 | 1494 | navigationCapabilities: h.NavigationCapabilities, | |
| 1504 | 1495 | terminalFocus: h.TerminalFocus, | |
| 1505 | 1496 | clientPID: h.ClientPID, | |
| 1497 | + | clientEnv: helloClientEnvironment(h), | |
| 1506 | 1498 | terminalCapabilities: terminalCapabilities, | |
| 1507 | 1499 | capabilitiesSet: true, | |
| 1508 | 1500 | } | |
| @@ -1819,7 +1811,8 @@ func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr port | |||
| 1819 | 1811 | } | |
| 1820 | 1812 | cwd := d.dirOrHome(stopped.cwd) | |
| 1821 | 1813 | env := h.Env | |
| 1822 | - | if h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned { | |
| 1814 | + | daemonOwned := h.EnvironmentPolicy == protocol.EnvironmentPolicyDaemonOwned | |
| 1815 | + | if daemonOwned { | |
| 1823 | 1816 | env = copyEnvironment(d.baseEnv) | |
| 1824 | 1817 | } | |
| 1825 | 1818 | var err error | |
| @@ -1828,6 +1821,11 @@ func (d *Daemon) routeWithContext(ctx context.Context, h protocol.Hello, tr port | |||
| 1828 | 1821 | d.mu.Unlock() | |
| 1829 | 1822 | 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 | 1829 | created = true | |
| 1832 | 1830 | } | |
| 1833 | 1831 | 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-terminal | 1481 | // Kitty graphics are enabled only by the explicit direct-terminal | ||
| 1491 | // declaration in Hello. Environment values remain useful for color and | 1482 | // 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.Env | 1813 | 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 error | 1818 | 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, err | 1822 | 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 = true | 1829 | 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) { | |||
| 230 | 230 | defer releaseAll(releases) | |
| 231 | 231 | movedTab := source.tabs[0] | |
| 232 | 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 | 238 | openMovePickerForTest(t, d, ac, source, protocol.PickerIntentMoveTab, moveSourceForSession(source, ac, "moved-tab", "")) | |
| 235 | 239 | effect := pickActionEffectForTest(t, source, ac) | |
| @@ -244,6 +248,9 @@ func TestMovePickerMoveCommitsTab(t *testing.T) { | |||
| 244 | 248 | require.Nil(t, source.tabs) | |
| 245 | 249 | require.Len(t, destination.tabs, 2) | |
| 246 | 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 | 256 | 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 | |||
| 246 | 246 | ||
| 247 | 247 | expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: action, | |
| 248 | 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 | 251 | if err != nil { | |
| 252 | 252 | // 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 | 544 | expectedSourceTab: guard.expectedSource, | |
| 545 | 545 | activateTargetTab: target.TabIndex >= 0, | |
| 546 | 546 | targetTabIndex: target.TabIndex, | |
| 547 | - | copySourceEnvironment: true, | |
| 548 | 547 | ready: true, | |
| 549 | 548 | }) | |
| 550 | 549 | d.mu.Lock() | |
| @@ -573,7 +572,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 573 | 572 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 574 | 573 | source: from, next: ac, | |
| 575 | 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 | 576 | createTargetLocked: func() (*session, error) { | |
| 578 | 577 | if d.purgeAdmissionClosedLocked() { | |
| 579 | 578 | return nil, errPurgeAdmissionClosed | |
| @@ -583,9 +582,13 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 583 | 582 | return nil, errAttachmentTransition | |
| 584 | 583 | } | |
| 585 | 584 | from.mu.Lock() | |
| 586 | - | cwd, env := d.dirOrHome(current.cwd), copyEnvironment(from.env) | |
| 585 | + | env, provisional := from.environmentSeedLocked() | |
| 587 | 586 | from.mu.Unlock() | |
| 587 | + | cwd := d.dirOrHome(current.cwd) | |
| 588 | 588 | created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames) | |
| 589 | + | if createErr == nil && provisional { | |
| 590 | + | markEnvironmentProvisional(created) | |
| 591 | + | } | |
| 589 | 592 | targetSess = created | |
| 590 | 593 | return created, createErr | |
| 591 | 594 | }, | |
| @@ -609,7 +612,7 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 609 | 612 | from.mu.Unlock() | |
| 610 | 613 | return nil, attachmentTransitionResult{}, false, nil | |
| 611 | 614 | } | |
| 612 | - | env := copyEnvironment(from.env) | |
| 615 | + | env, provisional := from.environmentSeedLocked() | |
| 613 | 616 | from.mu.Unlock() | |
| 614 | 617 | if d.purgeAdmissionClosedLocked() { | |
| 615 | 618 | return nil, attachmentTransitionResult{}, false, errPurgeAdmissionClosed | |
| @@ -624,6 +627,9 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||
| 624 | 627 | d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name) | |
| 625 | 628 | return nil, attachmentTransitionResult{}, false, err | |
| 626 | 629 | } | |
| 630 | + | if provisional { | |
| 631 | + | markEnvironmentProvisional(targetSess) | |
| 632 | + | } | |
| 627 | 633 | ||
| 628 | 634 | d.mu.Unlock() | |
| 629 | 635 | 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 for | 252 | // 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, errPurgeAdmissionClosed | 578 | return nil, errPurgeAdmissionClosed | ||
| @@ -583,9 +582,13 @@ func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, | |||||
| 583 | return nil, errAttachmentTransition | 582 | 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 = created | 592 | targetSess = created | ||
| 590 | return created, createErr | 593 | 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, nil | 613 | 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, errPurgeAdmissionClosed | 618 | 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, err | 628 | 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 | |||
| 88 | 88 | } | |
| 89 | 89 | 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 | 94 | ac, err := d.finishRouteAttach(sess, tr, h.Size, h, true, false) | |
| 92 | 95 | return sess, ac, err | |
| 93 | 96 | } | |
| @@ -88,6 +88,9 @@ func (d *Daemon) routeRemoteTargetWithContext(ctx context.Context, h protocol.He | |||||
| 88 | } | 88 | } | ||
| 89 | return nil, nil, err | 89 | 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, err | 95 | 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, | |||
| 626 | 626 | // The replacement terminal's focus, too, is declared before first paint. | |
| 627 | 627 | ac.setTerminalFocus(h.TerminalFocus) | |
| 628 | 628 | ac.clientPID.Store(h.ClientPID) | |
| 629 | + | ac.setClientEnvironment(helloClientEnvironment(h)) | |
| 629 | 630 | geometry := h.Geometry() | |
| 630 | 631 | if geometry.Size != sz { | |
| 631 | 632 | geometry = domain.Geometry{Size: sz} | |
| @@ -633,13 +634,6 @@ func (d *Daemon) resumeParkedLocked(h protocol.Hello, tr ports.ServerConnection, | |||
| 633 | 634 | ac.setGeometry(geometry) | |
| 634 | 635 | ac.resumeToken = d.nextResumeTokenLocked() | |
| 635 | 636 | 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 | 637 | preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID) | |
| 644 | 638 | // Resume preparation used sendMu -> d.mu, but freeze/drain must run with | |
| 645 | 639 | // 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 = false | 636 | 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 with | 638 | // Resume preparation used sendMu -> d.mu, but freeze/drain must run with | ||
| 645 | // neither held. Reacquire them before returning to preserve this helper's | 639 | // 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) { | |||
| 1561 | 1561 | require.Zero(t, ac.echoAck.Load()) | |
| 1562 | 1562 | } | |
| 1563 | 1563 | ||
| 1564 | - | func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) { | |
| 1564 | + | func TestResumeParkedRefreshesSessionBoundFuturePTYEnvironment(t *testing.T) { | |
| 1565 | 1565 | initialPTY, releaseInitial := newBlockingPTY(t) | |
| 1566 | 1566 | futurePTY, releaseFuture := newBlockingPTY(t) | |
| 1567 | 1567 | defer releaseInitial() | |
| @@ -1585,7 +1585,7 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) { | |||
| 1585 | 1585 | d := newTestDaemon(t, factory, stubClock{}) | |
| 1586 | 1586 | tr, _, _ := newConn(t, mustHello(protocol.IntentAttach, "unused", domain.Size{})) | |
| 1587 | 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 | 1589 | sess, ac, err := d.route(hello, tr) | |
| 1590 | 1590 | require.NoError(t, err) | |
| 1591 | 1591 | token := ac.resumeToken | |
| @@ -1593,7 +1593,7 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) { | |||
| 1593 | 1593 | require.True(t, d.parkAttachment(sess, ac)) | |
| 1594 | 1594 | ||
| 1595 | 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 | 1597 | resumeHello.TrueColor = true | |
| 1598 | 1598 | _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 1599 | 1599 | require.NoError(t, err) | |
| @@ -1603,10 +1603,10 @@ func TestResumeParkedReplacesFuturePTYEnvironment(t *testing.T) { | |||
| 1603 | 1603 | sess.mu.Lock() | |
| 1604 | 1604 | future := sess.tabs[1].focusedPane() | |
| 1605 | 1605 | 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 | 1607 | 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]) | |
| 1610 | 1610 | } | |
| 1611 | 1611 | ||
| 1612 | 1612 | func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) { | |
| @@ -1615,8 +1615,13 @@ func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) { | |||
| 1615 | 1615 | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 1616 | 1616 | d.baseEnv = []string{"DAEMON=owned"} | |
| 1617 | 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 | 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 | 1625 | sess.mu.Lock() | |
| 1621 | 1626 | sess.env = copyEnvironment(d.baseEnv) | |
| 1622 | 1627 | sess.mu.Unlock() | |
| @@ -1633,6 +1638,8 @@ func TestResumeParkedDaemonOwnedPreservesSessionEnvironment(t *testing.T) { | |||
| 1633 | 1638 | sess.mu.Lock() | |
| 1634 | 1639 | require.Equal(t, []string{"DAEMON=owned"}, sess.env) | |
| 1635 | 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 | 1644 | require.NoError(t, d.killSession(sess, protocol.ReasonSessionKilled, true)) | |
| 1638 | 1645 | } | |
| @@ -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.resumeToken | 1591 | 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 = true | 1597 | 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.stableID | 1605 | 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) { | |||
| 120 | 120 | failure := decodeServerMessage(t, failureFrame).(protocol.SamePeerSwitchFailure) | |
| 121 | 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 | + | } | |
| @@ -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 { | |||
| 63 | 63 | cwd string | |
| 64 | 64 | // env is the authoritative immutable environment snapshot for future PTY children. | |
| 65 | 65 | // 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 | |
| 69 | 76 | // snapshotMu serializes mutation revisions and repository publication | |
| 70 | 77 | // generations with worker completion. It is intentionally independent from | |
| 71 | 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 | 488 | from.mu.Lock() | |
| 482 | 489 | cwd := from.cwd | |
| 483 | - | env := copyEnvironment(from.env) | |
| 490 | + | env, provisional := from.environmentSeedLocked() | |
| 484 | 491 | _, attached := from.attachments[ac] | |
| 485 | 492 | from.mu.Unlock() | |
| 486 | 493 | if !attached { | |
| @@ -489,6 +496,9 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||
| 489 | 496 | } | |
| 490 | 497 | ||
| 491 | 498 | newSess, err := d.createSessionLockedWithMode(name, false, cwd, geometry, env) | |
| 499 | + | if err == nil && provisional { | |
| 500 | + | markEnvironmentProvisional(newSess) | |
| 501 | + | } | |
| 492 | 502 | d.mu.Unlock() | |
| 493 | 503 | if err != nil { | |
| 494 | 504 | return err | |
| @@ -537,7 +547,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(effect *attachmentEffect, n | |||
| 537 | 547 | source: capability.sess, next: capability.ac, | |
| 538 | 548 | ||
| 539 | 549 | expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-session", | |
| 540 | - | copySourceEnvironment: true, ready: true, | |
| 550 | + | ready: true, | |
| 541 | 551 | createTargetLocked: func() (*session, error) { | |
| 542 | 552 | source := capability.sess | |
| 543 | 553 | if source == nil { | |
| @@ -553,10 +563,14 @@ func (d *Daemon) createSessionAndSwitchForAttachment(effect *attachmentEffect, n | |||
| 553 | 563 | return nil, errSessionNameInUse | |
| 554 | 564 | } | |
| 555 | 565 | source.mu.Lock() | |
| 556 | - | cwd, env := source.cwd, copyEnvironment(source.env) | |
| 566 | + | cwd := source.cwd | |
| 567 | + | env, provisional := source.environmentSeedLocked() | |
| 557 | 568 | source.mu.Unlock() | |
| 558 | 569 | var createErr error | |
| 559 | 570 | created, createErr = d.createSessionLockedWithMode(name, false, cwd, capability.ac.geometrySnapshot(), env) | |
| 571 | + | if createErr == nil && provisional { | |
| 572 | + | markEnvironmentProvisional(created) | |
| 573 | + | } | |
| 560 | 574 | return created, createErr | |
| 561 | 575 | }, | |
| 562 | 576 | }) | |
| @@ -586,7 +600,8 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie | |||
| 586 | 600 | return errPurgeAdmissionClosed | |
| 587 | 601 | } | |
| 588 | 602 | from.mu.Lock() | |
| 589 | - | cwd, env := from.cwd, copyEnvironment(from.env) | |
| 603 | + | cwd := from.cwd | |
| 604 | + | env, provisional := from.environmentSeedLocked() | |
| 590 | 605 | _, attached := from.attachments[ac] | |
| 591 | 606 | from.mu.Unlock() | |
| 592 | 607 | if !attached { | |
| @@ -595,6 +610,9 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie | |||
| 595 | 610 | } | |
| 596 | 611 | name := d.allocEphemeralNameLocked() | |
| 597 | 612 | newSess, err := d.createSessionLockedWithMode(name, true, cwd, geometry, env) | |
| 613 | + | if err == nil && provisional { | |
| 614 | + | markEnvironmentProvisional(newSess) | |
| 615 | + | } | |
| 598 | 616 | d.mu.Unlock() | |
| 599 | 617 | if err != nil { | |
| 600 | 618 | return err | |
| @@ -626,7 +644,7 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachment | |||
| 626 | 644 | transition, err := d.transitionAttachment(attachmentTransitionRequest{ | |
| 627 | 645 | source: capability.sess, next: capability.ac, | |
| 628 | 646 | expectedTransport: capability.transport, sourceCapability: &capability, sourceEffect: effect, action: "create-ephemeral-session", | |
| 629 | - | copySourceEnvironment: true, ready: true, | |
| 647 | + | ready: true, | |
| 630 | 648 | createTargetLocked: func() (*session, error) { | |
| 631 | 649 | if capability.sess == nil || d.closing { | |
| 632 | 650 | return nil, errAttachmentTransition | |
| @@ -636,11 +654,15 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachment | |||
| 636 | 654 | } | |
| 637 | 655 | source := capability.sess | |
| 638 | 656 | source.mu.Lock() | |
| 639 | - | cwd, env := source.cwd, copyEnvironment(source.env) | |
| 657 | + | cwd := source.cwd | |
| 658 | + | env, provisional := source.environmentSeedLocked() | |
| 640 | 659 | source.mu.Unlock() | |
| 641 | 660 | name := d.allocEphemeralNameLocked() | |
| 642 | 661 | var createErr error | |
| 643 | 662 | created, createErr = d.createSessionLockedWithMode(name, true, cwd, capability.ac.geometrySnapshot(), env) | |
| 663 | + | if createErr == nil && provisional { | |
| 664 | + | markEnvironmentProvisional(created) | |
| 665 | + | } | |
| 644 | 666 | return created, createErr | |
| 645 | 667 | }, | |
| 646 | 668 | }) | |
| @@ -63,9 +63,16 @@ type session struct { | |||||
| 63 | cwd string | 63 | 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 []string | 66 | + | env []string |
| 67 | - | snapDirty atomic.Bool | 67 | + | // envProvisional marks an env that came from the daemon's own process |
| 68 | - | snapEligible atomic.Bool | 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 | |||
| 69 | // snapshotMu serializes mutation revisions and repository publication | 76 | // snapshotMu serializes mutation revisions and repository publication | ||
| 70 | // generations with worker completion. It is intentionally independent from | 77 | // 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.cwd | 489 | 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 err | 504 | 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.sess | 552 | 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, errSessionNameInUse | 563 | 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 error | 569 | 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, createErr | 574 | return created, createErr | ||
| 561 | }, | 575 | }, | ||
| 562 | }) | 576 | }) | ||
| @@ -586,7 +600,8 @@ func (d *Daemon) createEphemeralSessionAndSwitch(from *session, ac *attachedClie | |||||
| 586 | return errPurgeAdmissionClosed | 600 | 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 err | 618 | 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, errAttachmentTransition | 650 | return nil, errAttachmentTransition | ||
| @@ -636,11 +654,15 @@ func (d *Daemon) createEphemeralSessionAndSwitchForAttachment(effect *attachment | |||||
| 636 | } | 654 | } | ||
| 637 | source := capability.sess | 655 | 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 error | 661 | 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, createErr | 666 | 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) { | |||
| 554 | 554 | } | |
| 555 | 555 | ||
| 556 | 556 | // TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment proves a local admitted | |
| 557 | - | // attach refreshes the session's environment for future PTY children while the | |
| 558 | - | // already-running PTY is untouched, which is the existing shared-session | |
| 559 | - | // semantics the admission must preserve. | |
| 557 | + | // attach refreshes the session-bound variables for future PTY children while | |
| 558 | + | // the already-running PTY and ordinary variables are untouched. | |
| 560 | 559 | func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) { | |
| 561 | - | firstEnv := []string{"MARK=first"} | |
| 560 | + | firstEnv := []string{"MARK=first", "WAYLAND_DISPLAY=wayland-old"} | |
| 562 | 561 | pty, release := newBlockingPTY(t) | |
| 563 | 562 | defer release() | |
| 564 | 563 | d := newTestDaemon(t, newFactorySeq(t, pty, newQuietPTY()), stubClock{}) | |
| @@ -581,7 +580,7 @@ func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) { | |||
| 581 | 580 | // A later attach refreshes only the session's future-child environment. A | |
| 582 | 581 | // named admission authorizes creation only, so the attach rides a fresh | |
| 583 | 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 | 584 | target := protocol.ExactSessionTarget{LifecycleID: sess.incarnation, SessionName: sess.name} | |
| 586 | 585 | attach := protocol.Hello{ | |
| 587 | 586 | Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName, Size: defaultSize, | |
| @@ -595,13 +594,56 @@ func TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment(t *testing.T) { | |||
| 595 | 594 | require.NoError(t, err) | |
| 596 | 595 | ||
| 597 | 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 | 598 | require.Same(t, existingPane, sess.tabs[0].focusedPane(), "the existing PTY is untouched") | |
| 600 | 599 | sess.mu.Unlock() | |
| 601 | 600 | ||
| 602 | 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 | 647 | // TestRouteRemoteExactAttachPreservesSessionEnvironment proves a remote exact | |
| 606 | 648 | // attach never rewrites the live session's environment or working directory: | |
| 607 | 649 | // 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 admitted | 556 | // TestRouteLocalAttachUpdatesOnlyFutureChildEnvironment proves a local admitted | ||
| 557 | - | // attach refreshes the session's environment for future PTY children while the | 557 | + | // attach refreshes the session-bound variables for future PTY children while |
| 558 | - | // already-running PTY is untouched, which is the existing shared-session | 558 | + | // 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. A | 580 | // A later attach refreshes only the session's future-child environment. A | ||
| 582 | // named admission authorizes creation only, so the attach rides a fresh | 581 | // 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 exact | 647 | // 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 a | 649 | // 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) { | |||
| 1746 | 1746 | d.sessWg.Wait() | |
| 1747 | 1747 | } | |
| 1748 | 1748 | ||
| 1749 | - | func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) { | |
| 1749 | + | func TestAttachEnvironmentRefreshesOnlySessionBoundFuturePTYInputs(t *testing.T) { | |
| 1750 | 1750 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 1751 | 1751 | p1, release1 := newBlockingPTY(t) | |
| 1752 | 1752 | defer release1() | |
| @@ -1777,10 +1777,10 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) { | |||
| 1777 | 1777 | tr2.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 1778 | 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 | 1781 | sess, _, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "work", Size: sz, Env: first}, tr1) | |
| 1782 | 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 | 1784 | _, ac, err := d.route(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentAttach, Name: "work", Size: sz, Env: second}, tr2) | |
| 1785 | 1785 | require.NoError(t, err) | |
| 1786 | 1786 | defer func() { | |
| @@ -1789,12 +1789,15 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) { | |||
| 1789 | 1789 | }() | |
| 1790 | 1790 | ||
| 1791 | 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 | 1793 | require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator") | |
| 1794 | 1794 | 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") | |
| 1798 | 1801 | } | |
| 1799 | 1802 | ||
| 1800 | 1803 | 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 | |||
| 292 | 292 | func (d *Daemon) newRestoredSession(snap snapcodec.Session, sctx context.Context, cancel context.CancelFunc, tabs []*tab) *session { | |
| 293 | 293 | // Restored sessions keep ordered tabs; attachment repair deterministically | |
| 294 | 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 | 296 | // Restored tabs remain private until persistAndRegisterRestoredSession. | |
| 297 | 297 | // Initialize owners now so registration and reader startup cannot expose an | |
| 298 | 298 | // 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 deterministically | 293 | // 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 an | 297 | // Initialize owners now so registration and reader startup cannot expose an | ||
| 298 | // ownerless pane. | 298 | // ownerless pane. | ||