From 7af5cf72e68923f078ba63d57ea09dab7ffbe686 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 11 Aug 2026 09:11:53 -0400 Subject: [PATCH] Use the user's login shell, and fix login-shell argv Two separate problems made the terminal run the wrong shell: Pty.create took a single argv slice and used argv[0] as both the exec path and the argument vector's first entry. Session passed {shell, "-shell"}, so the shell received "-zsh" as an ordinary argument rather than as its argv[0]. Bash quietly tolerated it; zsh rejects it and exits immediately, which closed the tab and took the window down with it. The exec path and argv are now separate parameters. Shell resolution preferred $SHELL, which describes whichever shell launched us rather than the one the user configured. Inside 'nix develop' that is Nix's own minimal bash, so the terminal never opened the user's zsh. We now read the login shell from the passwd database and keep $SHELL only as a fallback for systems without a usable passwd entry. shot.sh no longer forces a shell, so screenshots reflect real behavior. --- README.md | 6 ++---- shot.sh | 4 ---- src/Pty.zig | 46 +++++++++++++++++++++++++++++++++++++++++++--- src/Session.zig | 18 +++++++++++++++--- 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f9d4e45..d7ed9d9 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,8 @@ in principle, but a terminal grid is small. ## What works -- Real shell on a real PTY, with a controlling terminal so job control, +- Your login shell (from the passwd database, not `$SHELL`) on a real PTY, + started as a login shell, with a controlling terminal so job control, Ctrl-C and SIGWINCH behave - Full SGR rendering: 16/256/true color, bold, italic, underline, strikethrough, inverse, and the bright-on-bold convention @@ -120,9 +121,6 @@ Sway** and screenshots it. This keeps UI checks entirely out of your real Wayland session — nothing appears on screen, and it works while the session is locked. -Screenshots use `$SHELL` like the real app does; set -`VTABS_SHOT_SHELL=/bin/sh` for output uncluttered by your shell's rc files. - One caveat when driving it: `wtype` loses the first keystroke of every invocation while the compositor adopts its freshly uploaded keymap, so scripted input should begin with a throwaway key. That is a quirk of the injection tool, diff --git a/shot.sh b/shot.sh index 7028bfd..ef1caee 100755 --- a/shot.sh +++ b/shot.sh @@ -40,10 +40,6 @@ export WLR_RENDERER=pixman export LIBGL_ALWAYS_SOFTWARE=1 export GSK_RENDERER=cairo export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" -# The nix dev shell points SHELL at a minimal bash (no readline, no -# programmable completion), which makes screenshots look broken for reasons -# that have nothing to do with vtabs. Use the system shell instead. -export SHELL="${VTABS_SHOT_SHELL:-/bin/bash}" export SWAYSOCK="$RUNDIR/sway.sock" # Don't let the app fall back to the real session. unset DISPLAY diff --git a/src/Pty.zig b/src/Pty.zig index 9de14ac..b4de210 100644 --- a/src/Pty.zig +++ b/src/Pty.zig @@ -41,6 +41,14 @@ const c = struct { extern "c" fn _exit(code: c_int) noreturn; extern "c" fn kill(pid: pid_t, sig: c_int) c_int; extern "c" fn waitpid(pid: pid_t, status: ?*c_int, options: c_int) pid_t; + extern "c" fn getuid() std.c.uid_t; + extern "c" fn getpwuid_r( + uid: std.c.uid_t, + pwd: *std.c.passwd, + buf: [*]u8, + buflen: usize, + result: *?*std.c.passwd, + ) c_int; }; const O_RDWR = 0x0002; @@ -69,11 +77,18 @@ pub const Error = error{ ForkFailed, }; -/// Open a PTY pair and fork `argv` onto the slave side. The child gets its +/// Open a PTY pair and fork `path` onto the slave side. The child gets its /// own session with the slave as controlling terminal, so job control and /// signals (Ctrl-C, SIGWINCH) work the way a shell expects. +/// +/// `path` is the executable to run and `argv` is the argument vector it +/// sees, including argv[0]. These are separate because a login shell is +/// started by running e.g. /usr/bin/zsh with an argv[0] of "-zsh"; folding +/// them together would pass "-zsh" as an ordinary argument instead, which +/// shells either misparse or reject outright. pub fn create( alloc: std.mem.Allocator, + path: [:0]const u8, argv: []const [:0]const u8, size: Winsize, ) !Pty { @@ -105,7 +120,7 @@ pub fn create( const pid = c.fork(); if (pid < 0) return Error.ForkFailed; if (pid == 0) { - childExec(master, slave_path_z, argv_z, envp_z); + childExec(master, slave_path_z, path, argv_z, envp_z); // childExec only returns on failure, and a forked child has no // sensible way to report that back to us. c._exit(127); @@ -118,6 +133,7 @@ pub fn create( fn childExec( master: fd_t, slave_path: [:0]const u8, + path: [:0]const u8, argv: [:null]const ?[*:0]const u8, envp: [:null]const ?[*:0]const u8, ) void { @@ -139,7 +155,31 @@ fn childExec( if (c.dup2(slave, 2) < 0) return; if (slave > 2) _ = c.close(slave); - _ = c.execvpe(argv[0].?, argv.ptr, envp.ptr); + _ = c.execvpe(path.ptr, argv.ptr, envp.ptr); +} + +/// The login shell recorded for the current user in the passwd database, +/// copied into `buf`. Returns null if there is no entry or it names no shell. +/// +/// This is the shell the user actually configured (what `chsh` sets). Unlike +/// $SHELL it cannot be overwritten by whatever environment the terminal was +/// launched from. +pub fn loginShell(buf: []u8) ?[]const u8 { + // getpwuid_r writes the strings it returns into a caller-provided scratch + // buffer, kept separate from `buf` so the result survives the copy out. + var scratch: [4096]u8 = undefined; + var pw: std.c.passwd = undefined; + var result: ?*std.c.passwd = null; + + if (c.getpwuid_r(c.getuid(), &pw, &scratch, scratch.len, &result) != 0) return null; + const entry = result orelse return null; + const shell = entry.shell orelse return null; + + const span = std.mem.span(shell); + if (span.len == 0 or span.len > buf.len) return null; + + @memcpy(buf[0..span.len], span); + return buf[0..span.len]; } /// Copy the current environment, forcing the variables that describe what diff --git a/src/Session.zig b/src/Session.zig index c8aed18..7e0fd6e 100644 --- a/src/Session.zig +++ b/src/Session.zig @@ -96,13 +96,14 @@ pub fn create( const shell = try defaultShell(alloc); defer alloc.free(shell); - // A leading '-' in argv[0] tells the shell to start as a login shell. + // A leading '-' in argv[0] tells the shell to start as a login shell, + // so it reads the user's profile. const argv0 = try std.fmt.allocPrintSentinel(alloc, "-{s}", .{ std.fs.path.basename(shell), }, 0); defer alloc.free(argv0); - self.pty = try .create(alloc, &.{ shell, argv0 }, .{ + self.pty = try .create(alloc, shell, &.{argv0}, .{ .ws_row = rows, .ws_col = cols, }); @@ -126,12 +127,23 @@ pub fn destroy(self: *Session) void { self.alloc.destroy(self); } -/// Resolve the user's shell, falling back to something that always exists. +/// Resolve the shell to run, most authoritative source first. +/// +/// The passwd entry comes first deliberately. $SHELL is only a description of +/// the shell that happened to launch us, and plenty of environments overwrite +/// it: `nix develop` replaces it with its own bash, and Flatpak pins it to +/// /bin/sh. The passwd entry is what the user actually configured, so it is +/// the better answer to "the user's default shell". $SHELL remains a fallback +/// for systems with no usable passwd entry, such as minimal containers. fn defaultShell(alloc: std.mem.Allocator) ![:0]const u8 { + var buf: [std.fs.max_path_bytes]u8 = undefined; + if (Pty.loginShell(&buf)) |shell| return alloc.dupeZ(u8, shell); + if (std.c.getenv("SHELL")) |sh| { const span = std.mem.span(sh); if (span.len > 0) return alloc.dupeZ(u8, span); } + return alloc.dupeZ(u8, "/bin/sh"); }