a73x

5cada44f

feat(shellint): OSC 133 marks injected at spawn — ZDOTDIR shim, --init-file, vendor_conf.d

a73x   2026-08-13 17:10

Commit message
feat(shellint): OSC 133 marks injected at spawn — ZDOTDIR shim, --init-file, vendor_conf.d

Everything before this could read marks; nothing made a real shell emit
them. muxd forks the session shell itself, which is the whole opportunity:
injection is env plus argv at spawn time, so a session speaks marks with no
rc-file edit on the box and nothing left behind when the daemon exits.

The three shells need three different doors. zsh has no --init-file, so it
gets ZDOTDIR pointed at a shim directory whose .zshrc restores the user's
ZDOTDIR (or unsets it — an exported empty one would break zsh's fallback to
$HOME) and then sources their real rc. bash gets --init-file, which REPLACES
~/.bashrc rather than adding to it, so the shim sources it first. fish gets a
vendor_conf.d entry via XDG_DATA_DIRS. Anything else is `.other` and gets
nothing: a /bin/sh session is byte-identical to the one it was before this
commit, which is what the existing server tests depend on.

Pty grows an `env` option on the one child-setup path, applied after TERM.
It spells its own EnvPair rather than importing shellint — a pty knows how
to hand a child an environment and has no business knowing that shell
integration is what wants one — so the daemon, which knows about both, maps
between them in one loop.

The shims live beside the socket (already private, already runtime-appropriate,
already per-user), pid-suffixed so two daemons sharing a socket directory do
not share shims, 0700 over 0600 files because what lands there runs code as
this user. deinit removes the tree after the pty, so the shell is gone before
the files it was reading are.

Proven against the real shells on this box, not by grepping the scripts:
deleting bash's DEBUG trap and zsh's preexec hook each fail their own e2e
test with NoReturnedPush, and disabling the teardown leaves the directory
behind for the test that says it should not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -195,6 +195,16 @@ pub fn build(b: *std.Build) void {
195 cmd_mod.addImport("engine", engine_mod); 195 cmd_mod.addImport("engine", engine_mod);
196 cmd_mod.addImport("protocol", protocol_mod); 196 cmd_mod.addImport("protocol", protocol_mod);
197 197
198 // Shell integration: the OSC 133 mark scripts and what a spawn must add
199 // to hand them to a shell. A leaf on purpose — it writes files and reads
200 // the environment, and knows nothing of ptys, servers or the protocol,
201 // so its tests need no daemon and no socket.
202 const shellint_mod = b.createModule(.{
203 .root_source_file = b.path("src/shellint.zig"),
204 .target = target,
205 .optimize = optimize,
206 });
207
198 // The replay core: snapshot/delta application and the resume 208 // The replay core: snapshot/delta application and the resume
199 // coordinates, shared by the CLI client, the wasm core, and the 209 // coordinates, shared by the CLI client, the wasm core, and the
200 // server's test fixtures. Engine plus protocol and nothing else, and 210 // server's test fixtures. Engine plus protocol and nothing else, and
@@ -247,6 +257,7 @@ pub fn build(b: *std.Build) void {
247 server_mod.addImport("protocol", protocol_mod); 257 server_mod.addImport("protocol", protocol_mod);
248 server_mod.addImport("delta", delta_mod); 258 server_mod.addImport("delta", delta_mod);
249 server_mod.addImport("cmd", cmd_mod); 259 server_mod.addImport("cmd", cmd_mod);
260 server_mod.addImport("shellint", shellint_mod);
250 server_mod.addImport("replica", replica_mod); 261 server_mod.addImport("replica", replica_mod);
251 server_mod.addImport("sockpath", sockpath_mod); 262 server_mod.addImport("sockpath", sockpath_mod);
252 // Both: the listener it owns, and the vocabulary it names directly 263 // Both: the listener it owns, and the vocabulary it names directly
@@ -533,7 +544,7 @@ pub fn build(b: *std.Build) void {
533 b.installArtifact(webhub_exe); 544 b.installArtifact(webhub_exe);
534 545
535 const test_step = b.step("test", "Run unit tests"); 546 const test_step = b.step("test", "Run unit tests");
536 // delta_mod, cmd_mod, and sockpath_mod sit BEFORE server_mod, 547 // delta_mod, cmd_mod, shellint_mod and sockpath_mod sit BEFORE server_mod,
537 // deliberately: their tests are seconds-long and socket-free, while a 548 // deliberately: their tests are seconds-long and socket-free, while a
538 // regression in any of them can wedge a server test that waits on a 549 // regression in any of them can wedge a server test that waits on a
539 // client forever — and a wedged step prints nothing at all. Failing 550 // client forever — and a wedged step prints nothing at all. Failing
@@ -544,7 +555,7 @@ pub fn build(b: *std.Build) void {
544 // absence here was a live hazard recorded in decisions.md — muxd's 555 // absence here was a live hazard recorded in decisions.md — muxd's
545 // entrypoint could grow tests that silently never ran, exactly as 556 // entrypoint could grow tests that silently never ran, exactly as
546 // mux_main.zig's five did before it was added. 557 // mux_main.zig's five did before it was added.
547 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, cmd_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod, wsclient_mod}) |mod| { 558 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, cmd_mod, shellint_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod, wsclient_mod}) |mod| {
548 const t = b.addTest(.{ .root_module = mod }); 559 const t = b.addTest(.{ .root_module = mod });
549 t.use_llvm = true; 560 t.use_llvm = true;
550 t.use_lld = true; 561 t.use_lld = true;
src/main.zig
Old New
@@ -436,11 +436,22 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
436 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); 436 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh");
437 defer alloc.free(shell_z); 437 defer alloc.free(shell_z);
438 438
439 // Read from the DAEMON's environment, necessarily: muxd forks the
440 // session shell, so by the time anyone could pass a flag through a
441 // client the shell has been running for a while. `MUX_SHELL_INTEGRATION=0`
442 // and nothing else — any other value, including unset, means on.
443 const shell_integration = !std.mem.eql(
444 u8,
445 std.posix.getenv("MUX_SHELL_INTEGRATION") orelse "",
446 "0",
447 );
448
439 var srv = Server.init(alloc, .{ 449 var srv = Server.init(alloc, .{
440 .sock_path = sock_path, 450 .sock_path = sock_path,
441 .shell = shell_z, 451 .shell = shell_z,
442 .cols = o.cols, 452 .cols = o.cols,
443 .rows = o.rows, 453 .rows = o.rows,
454 .shell_integration = shell_integration,
444 }) catch |err| switch (err) { 455 }) catch |err| switch (err) {
445 // All of these mean "that path is not ours to take", and all 456 // All of these mean "that path is not ours to take", and all
446 // are ordinary operator mistakes rather than daemon bugs: say 457 // are ordinary operator mistakes rather than daemon bugs: say
src/pty.zig
Old New
@@ -24,6 +24,13 @@ pub const Pty = struct {
24 return spawnArgv(.{ .cols = opts.cols, .rows = opts.rows, .argv = &argv }); 24 return spawnArgv(.{ .cols = opts.cols, .rows = opts.rows, .argv = &argv });
25 } 25 }
26 26
27 /// One variable to set in the child. Spelled here rather than imported
28 /// so this module stays a leaf: a pty knows how to hand a child an
29 /// environment, and deliberately does not know that shell integration
30 /// is what currently wants one. The daemon maps its own pairs onto
31 /// these — one loop, and the layering stays the right way up.
32 pub const EnvPair = struct { key: [:0]const u8, value: [:0]const u8 };
33
27 pub const SpawnArgvOptions = struct { 34 pub const SpawnArgvOptions = struct {
28 cols: u16, 35 cols: u16,
29 rows: u16, 36 rows: u16,
@@ -34,6 +41,9 @@ pub const Pty = struct {
34 /// The e2e fixture uses this to keep predict stats out of the 41 /// The e2e fixture uses this to keep predict stats out of the
35 /// capture, matching the suite's `.err` sibling convention. 42 /// capture, matching the suite's `.err` sibling convention.
36 stderr_fd: ?std.posix.fd_t = null, 43 stderr_fd: ?std.posix.fd_t = null,
44 /// Set in the child between fork and exec, after TERM. Injection's
45 /// door: the daemon's env is the only source of a child's env.
46 env: []const EnvPair = &.{},
37 }; 47 };
38 48
39 /// The one child-setup path: everything that has to be true of a process 49 /// The one child-setup path: everything that has to be true of a process
@@ -60,6 +70,11 @@ pub const Pty = struct {
60 // Child. xterm-256color: ghostty-vt understands more, but this 70 // Child. xterm-256color: ghostty-vt understands more, but this
61 // terminfo exists everywhere the shell will look. 71 // terminfo exists everywhere the shell will look.
62 _ = c.setenv("TERM", "xterm-256color", 1); 72 _ = c.setenv("TERM", "xterm-256color", 1);
73 // After TERM so a caller could override it, and before the
74 // signal work so the environment is settled whatever follows.
75 // Overwrite (1): the daemon's own value for a name it was
76 // handed is not the one it means the child to see.
77 for (opts.env) |kv| _ = c.setenv(kv.key.ptr, kv.value.ptr, 1);
63 78
64 // Ctrl-C must work in the session, and without this it does not. 79 // Ctrl-C must work in the session, and without this it does not.
65 // A non-interactive shell sets SIGINT and SIGQUIT to SIG_IGN for 80 // A non-interactive shell sets SIGINT and SIGQUIT to SIG_IGN for
@@ -359,6 +374,26 @@ test "Pty: spawnArgv applies the requested winsize" {
359 try std.testing.expect(std.mem.indexOf(u8, out.items, "31 101") != null); 374 try std.testing.expect(std.mem.indexOf(u8, out.items, "31 101") != null);
360 } 375 }
361 376
377 test "Pty: spawnArgv env pairs reach the child" {
378 // The child prints the variable rather than being asked about it: an
379 // exported name that the exec'd process cannot read is the failure
380 // this guards, so the assertion has to come from inside the child.
381 var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "printf 'env-%s' \"$MUX_T\"" };
382 var pty = try Pty.spawnArgv(.{
383 .cols = 80,
384 .rows = 24,
385 .argv = &argv,
386 .env = &.{.{ .key = "MUX_T", .value = "ok" }},
387 });
388 defer pty.deinit();
389
390 var out = try readUntil(std.testing.allocator, &pty, "env-ok", 5000);
391 defer out.deinit(std.testing.allocator);
392 // "env-" alone would appear for an unset variable too, which is exactly
393 // the broken case; the value is what makes this an assertion.
394 try std.testing.expect(std.mem.indexOf(u8, out.items, "env-ok") != null);
395 }
396
362 test "Pty: spawnArgv redirects stderr off the pty when asked" { 397 test "Pty: spawnArgv redirects stderr off the pty when asked" {
363 const pipe = try std.posix.pipe(); 398 const pipe = try std.posix.pipe();
364 defer std.posix.close(pipe[0]); 399 defer std.posix.close(pipe[0]);
src/server.zig
Old New
@@ -11,6 +11,7 @@ const Pty = @import("pty").Pty;
11 const proto = @import("protocol"); 11 const proto = @import("protocol");
12 const DeltaTracker = @import("delta").DeltaTracker; 12 const DeltaTracker = @import("delta").DeltaTracker;
13 const cmdmod = @import("cmd"); 13 const cmdmod = @import("cmd");
14 const shellint = @import("shellint");
14 // Test-only consumer (applyFrame): the tests replay daemon frames through 15 // Test-only consumer (applyFrame): the tests replay daemon frames through
15 // the production client's replay core rather than a hand-rolled twin. 16 // the production client's replay core rather than a hand-rolled twin.
16 const replica_mod = @import("replica"); 17 const replica_mod = @import("replica");
@@ -315,6 +316,15 @@ pub const Server = struct {
315 /// 0 means the session has never said anything, which no amount of 316 /// 0 means the session has never said anything, which no amount of
316 /// elapsed silence should be read as a command having finished. 317 /// elapsed silence should be read as a command having finished.
317 last_pty_ms: i64 = 0, 318 last_pty_ms: i64 = 0,
319 /// Holds every string the shell-integration injection handed the spawn:
320 /// the shim directory's path, the argv, the env pairs. An arena because
321 /// they are allocated once, in init, and freed once, together.
322 shellint_arena: std.heap.ArenaAllocator,
323 /// The shim directory to remove at teardown, or null when nothing was
324 /// written — integration off, or a shell this daemon has no scripts for.
325 /// Distinct from "the arena is empty": only a directory that exists is
326 /// one we are responsible for deleting.
327 shellint_dir: ?[]const u8 = null,
318 stats: Stats = .{}, 328 stats: Stats = .{},
319 329
320 pub const Options = struct { 330 pub const Options = struct {
@@ -322,6 +332,12 @@ pub const Server = struct {
322 shell: [:0]const u8, 332 shell: [:0]const u8,
323 cols: u16 = 80, 333 cols: u16 = 80,
324 rows: u16 = 24, 334 rows: u16 = 24,
335 /// Inject the OSC 133 mark scripts into the session shell. On by
336 /// default: marks are what make an exit code knowable, and every
337 /// fallback below them is a guess. `muxd run` turns it off for
338 /// `MUX_SHELL_INTEGRATION=0`, and a shell shellint has no scripts
339 /// for is unaffected either way.
340 shell_integration: bool = true,
325 }; 341 };
326 342
327 pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { 343 pub fn init(alloc: std.mem.Allocator, opts: Options) !Server {
@@ -332,7 +348,60 @@ pub const Server = struct {
332 const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows }); 348 const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows });
333 errdefer eng.deinit(); 349 errdefer eng.deinit();
334 350
335 var pty = try Pty.spawn(.{ .cols = opts.cols, .rows = opts.rows, .shell = opts.shell }); 351 // Shell integration, decided and written before the fork: whatever
352 // the child is going to be told has to exist on disk by the time it
353 // execs, and a failure here is still cheap — no process yet.
354 var shellint_arena = std.heap.ArenaAllocator.init(alloc);
355 errdefer shellint_arena.deinit();
356 var shellint_dir: ?[]const u8 = null;
357 var injection: shellint.Injection = .{ .extra_argv = &.{}, .env = &.{} };
358 if (opts.shell_integration) {
359 const a = shellint_arena.allocator();
360 // Beside the socket: that directory is already private, already
361 // runtime-appropriate and already per-user, which is three
362 // properties the shims need and none of them are ours to
363 // re-derive. The pid keeps two daemons sharing one socket
364 // directory out of each other's shims.
365 const parent = std.fs.path.dirname(opts.sock_path) orelse ".";
366 const dir = try std.fmt.allocPrint(
367 a,
368 "{s}/mux-shellint-{d}",
369 .{ parent, std.os.linux.getpid() },
370 );
371 injection = try shellint.prepare(a, dir, opts.shell);
372 // Only a shell we actually wrote scripts for leaves a directory
373 // behind. Recording the path unconditionally would make teardown
374 // delete-tree a path nothing ever created — harmless today, and
375 // exactly the kind of "the cleanup claims work it did not do"
376 // this project has been burned by.
377 if (shellint.detect(opts.shell) != .other) shellint_dir = dir;
378 }
379 // shellint speaks its own EnvPair so it can stay a leaf, and so can
380 // pty; the daemon is the one place that knows about both, so the
381 // mapping lives here. The arena outlives the spawn, as spawnArgv
382 // requires of anything it reads in the child.
383 const env = try shellint_arena.allocator().alloc(Pty.EnvPair, injection.env.len);
384 for (injection.env, env) |src, *dst| {
385 dst.* = .{ .key = src.key, .value = src.value };
386 }
387 // argv is the shell plus whatever the injection adds, null-terminated
388 // for execve. With no extra argv and no env this is byte-identical to
389 // the old `Pty.spawn` call, which is what keeps a /bin/sh session
390 // exactly the session it was before shell integration existed.
391 const argv = try shellint_arena.allocator().allocSentinel(
392 ?[*:0]const u8,
393 1 + injection.extra_argv.len,
394 null,
395 );
396 argv[0] = opts.shell.ptr;
397 for (injection.extra_argv, argv[1..]) |src, *dst| dst.* = src.ptr;
398
399 var pty = try Pty.spawnArgv(.{
400 .cols = opts.cols,
401 .rows = opts.rows,
402 .argv = argv.ptr,
403 .env = env,
404 });
336 errdefer pty.deinit(); 405 errdefer pty.deinit();
337 406
338 // Random rather than a counter or a timestamp: nothing on disk 407 // Random rather than a counter or a timestamp: nothing on disk
@@ -352,6 +421,8 @@ pub const Server = struct {
352 .sock_path = opts.sock_path, 421 .sock_path = opts.sock_path,
353 .path_id = path_id, 422 .path_id = path_id,
354 .epoch = epoch, 423 .epoch = epoch,
424 .shellint_arena = shellint_arena,
425 .shellint_dir = shellint_dir,
355 }; 426 };
356 } 427 }
357 428
@@ -403,6 +474,12 @@ pub const Server = struct {
403 } 474 }
404 self.tracker.deinit(self.alloc); 475 self.tracker.deinit(self.alloc);
405 self.pty.deinit(); 476 self.pty.deinit();
477 // After the pty, so the shell is gone before the files it was
478 // reading are: a shim removed out from under a live shell would be
479 // a session that half-sourced its own integration. Best-effort —
480 // a daemon that cannot tidy /tmp must still exit.
481 if (self.shellint_dir) |dir| std.fs.cwd().deleteTree(dir) catch {};
482 self.shellint_arena.deinit();
406 self.eng.deinit(); 483 self.eng.deinit();
407 } 484 }
408 485
@@ -4820,6 +4897,162 @@ test "Server: OSC 133 marks reach attached clients as cmd_state pushes" {
4820 try std.testing.expect(returned.seq > 0); 4897 try std.testing.expect(returned.seq > 0);
4821 } 4898 }
4822 4899
4900 // ---------------------------------------------------------------------------
4901 // Shell integration, end to end. Everything above this line proves the daemon
4902 // can read marks; these two prove a real shell EMITS them, which is the only
4903 // version of the claim that matters in a session. The chain under test is
4904 // injection -> shell -> pty -> engine -> tracker -> wire, and no part of it is
4905 // stubbed.
4906 // ---------------------------------------------------------------------------
4907
4908 /// Pump until a cmd_state push says a command returned, and hand back what it
4909 /// said. Deliberately tolerant of what arrives first: an integrated shell
4910 /// emits a bare `A` for its opening prompt and a `C` when the command starts,
4911 /// so several cmd_state frames precede the interesting one and a test that
4912 /// insisted on the first would be asserting the shell's prompt timing.
4913 fn awaitReturn(
4914 alloc: std.mem.Allocator,
4915 srv: *Server,
4916 fd: std.posix.fd_t,
4917 budget_ms: i64,
4918 ) !?proto.CmdState {
4919 const deadline = std.time.milliTimestamp() + budget_ms;
4920 while (std.time.milliTimestamp() < deadline) {
4921 _ = try srv.pumpOnce(5);
4922 var pfd = [_]std.posix.pollfd{
4923 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
4924 };
4925 if ((std.posix.poll(&pfd, 1) catch 0) == 0) continue;
4926 const frame = (try proto.readFrame(alloc, fd)) orelse return null;
4927 defer frame.deinit(alloc);
4928 if (frame.type != .cmd_state) continue;
4929 const st = try proto.decodeCmdState(frame.payload);
4930 if (st.phase == .returned) return st;
4931 }
4932 return null;
4933 }
4934
4935 /// Run one command through a real shell with integration injected, and
4936 /// return the daemon's verdict on it. `shell` must exist; the callers
4937 /// skip rather than fail when it does not.
4938 fn runIntegrated(alloc: std.mem.Allocator, shell: [:0]const u8, tag: []const u8) !proto.CmdState {
4939 var tmp = try TmpDir.make();
4940 defer tmp.cleanup();
4941 const sock_path = try std.fmt.allocPrint(alloc, "{s}/{s}.sock", .{ tmp.path(), tag });
4942 defer alloc.free(sock_path);
4943
4944 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = shell });
4945 defer srv.deinit();
4946
4947 // The shim really was written where the daemon says it was: if this
4948 // directory were missing, the shell would start fine and simply emit
4949 // nothing, and the timeout below would report "no marks" without ever
4950 // saying why.
4951 try std.testing.expect(srv.shellint_dir != null);
4952 try std.fs.cwd().access(srv.shellint_dir.?, .{});
4953
4954 const c = try std.net.connectUnixSocket(sock_path);
4955 defer c.close();
4956 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
4957
4958 // `true` because it is a builtin in both shells and exits 0 without
4959 // output: the only thing the pty carries back for it is the marks and
4960 // the echo of the line, so a `returned` push cannot have come from
4961 // anywhere else.
4962 try proto.writeFrame(c.handle, .input, "true\n");
4963
4964 // 10s is a budget for a loaded box sourcing somebody's real rc files,
4965 // not an expectation — the shells here answer in tens of milliseconds.
4966 return (try awaitReturn(alloc, &srv, c.handle, 10_000)) orelse error.NoReturnedPush;
4967 }
4968
4969 test "Server: bash sessions emit marks with no rc-file edits" {
4970 const alloc = std.testing.allocator;
4971 std.fs.cwd().access("/bin/bash", .{}) catch return error.SkipZigTest;
4972
4973 const st = try runIntegrated(alloc, "/bin/bash", "bash");
4974 // marks, not pgid: the fallbacks could also notice a command ending, so
4975 // this field is what separates "integration works" from "the heuristic
4976 // covered for it".
4977 try std.testing.expectEqual(proto.Mechanism.marks, st.mechanism);
4978 // And the exit code, which no fallback can produce at all.
4979 try std.testing.expectEqual(@as(?u8, 0), st.exit_code);
4980 }
4981
4982 test "Server: zsh sessions emit marks with no rc-file edits" {
4983 const alloc = std.testing.allocator;
4984 // Both spellings, because the box that has zsh does not always agree
4985 // with the box that had it last.
4986 const shell: [:0]const u8 = blk: {
4987 for ([_][:0]const u8{ "/usr/bin/zsh", "/bin/zsh" }) |p| {
4988 std.fs.cwd().access(p, .{}) catch continue;
4989 break :blk p;
4990 }
4991 return error.SkipZigTest;
4992 };
4993
4994 const st = try runIntegrated(alloc, shell, "zsh");
4995 try std.testing.expectEqual(proto.Mechanism.marks, st.mechanism);
4996 try std.testing.expectEqual(@as(?u8, 0), st.exit_code);
4997 }
4998
4999 test "Server: the shim directory is private, and teardown takes it with it" {
5000 const alloc = std.testing.allocator;
5001 std.fs.cwd().access("/bin/bash", .{}) catch return error.SkipZigTest;
5002
5003 var tmp = try TmpDir.make();
5004 defer tmp.cleanup();
5005 const sock_path = try std.fmt.allocPrint(alloc, "{s}/shim.sock", .{tmp.path()});
5006 defer alloc.free(sock_path);
5007
5008 // Copied out of the server's arena before deinit frees it: the whole
5009 // point of this test is to ask a question after the server is gone.
5010 var dir_buf: [256]u8 = undefined;
5011 var dir: []const u8 = undefined;
5012 {
5013 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/bash" });
5014 defer srv.deinit();
5015 dir = try std.fmt.bufPrint(&dir_buf, "{s}", .{srv.shellint_dir.?});
5016
5017 // Beside the socket, not somewhere world-readable: the file the
5018 // session shell is about to source is a file that runs code as this
5019 // user, so 0700 on the directory is part of the contract.
5020 try std.testing.expectEqualStrings(std.fs.path.dirname(sock_path).?, std.fs.path.dirname(dir).?);
5021 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
5022 defer d.close();
5023 const st = try d.stat();
5024 try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(st.mode & 0o777)));
5025 }
5026
5027 // A daemon that left its shims behind would litter the runtime directory
5028 // once per session, and nothing else in the system would ever notice.
5029 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(dir, .{}));
5030 }
5031
5032 test "Server: an unknown shell is not injected into at all — no directory, no shim" {
5033 const alloc = std.testing.allocator;
5034
5035 var tmp = try TmpDir.make();
5036 defer tmp.cleanup();
5037 const sock_path = try std.fmt.allocPrint(alloc, "{s}/plain.sock", .{tmp.path()});
5038 defer alloc.free(sock_path);
5039
5040 // Integration is ON, and /bin/sh still gets nothing: this is what keeps
5041 // every other test in this file describing the session it always did.
5042 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
5043 defer srv.deinit();
5044 try std.testing.expectEqual(@as(?[]const u8, null), srv.shellint_dir);
5045
5046 // Nothing was written beside the socket either — the absence is on disk,
5047 // not merely in a field the teardown consults.
5048 var d = try std.fs.cwd().openDir(tmp.path(), .{ .iterate = true });
5049 defer d.close();
5050 var it = d.iterate();
5051 while (try it.next()) |entry| {
5052 try std.testing.expect(!std.mem.startsWith(u8, entry.name, "mux-shellint-"));
5053 }
5054 }
5055
4823 test "Server: status_req is answered on an attached client and on a bare observer" { 5056 test "Server: status_req is answered on an attached client and on a bare observer" {
4824 const alloc = std.testing.allocator; 5057 const alloc = std.testing.allocator;
4825 5058
src/shellint.zig
Old New
@@ -0,0 +1,285 @@
1 //! Shell integration: OSC 133 marks injected at spawn. muxd forks the
2 //! session shell itself, so injection is env + argv at spawn time — no
3 //! rc-file edits, ever. Detection is by shell basename; unknown shells get
4 //! nothing and the session runs on the pgid/settle fallbacks.
5 const std = @import("std");
6
7 pub const zsh_zshrc =
8 \\# mux shell integration (zsh): OSC 133 marks. Sourced via a ZDOTDIR
9 \\# shim; restores the user's ZDOTDIR (or unsets it) then runs their rc.
10 \\if [[ -n "$MUX_ORIG_ZDOTDIR" ]]; then
11 \\ export ZDOTDIR="$MUX_ORIG_ZDOTDIR"
12 \\ unset MUX_ORIG_ZDOTDIR
13 \\else
14 \\ unset ZDOTDIR
15 \\fi
16 \\[[ -f "${ZDOTDIR:-$HOME}/.zshrc" ]] && source "${ZDOTDIR:-$HOME}/.zshrc"
17 \\autoload -Uz add-zsh-hook
18 \\_mux_preexec() { _mux_ran=1; printf '\e]133;C\a'; }
19 \\_mux_precmd() {
20 \\ local code=$?
21 \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code"
22 \\ _mux_ran=""
23 \\ printf '\e]133;A\a'
24 \\}
25 \\add-zsh-hook preexec _mux_preexec
26 \\add-zsh-hook precmd _mux_precmd
27 \\
28 ;
29
30 pub const bash_init =
31 \\# mux shell integration (bash): OSC 133 marks. Passed via --init-file;
32 \\# sources the user's normal rc first so their config still runs.
33 \\[[ -f "$HOME/.bashrc" ]] && source "$HOME/.bashrc"
34 \\_mux_ran=""
35 \\_mux_preexec() {
36 \\ [[ -n "$COMP_LINE" ]] && return
37 \\ [[ "$BASH_COMMAND" == _mux_precmd* ]] && return
38 \\ _mux_ran=1
39 \\ printf '\e]133;C\a'
40 \\}
41 \\_mux_precmd() {
42 \\ local code=$?
43 \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code"
44 \\ _mux_ran=""
45 \\ printf '\e]133;A\a'
46 \\}
47 \\trap '_mux_preexec' DEBUG
48 \\PROMPT_COMMAND="_mux_precmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
49 \\
50 ;
51
52 pub const fish_conf =
53 \\# mux shell integration (fish): OSC 133 marks, via vendor_conf.d.
54 \\function _mux_preexec --on-event fish_preexec
55 \\ printf '\e]133;C\a'
56 \\end
57 \\function _mux_postexec --on-event fish_postexec
58 \\ printf '\e]133;D;%s\a' $status
59 \\end
60 \\function _mux_prompt --on-event fish_prompt
61 \\ printf '\e]133;A\a'
62 \\end
63 \\
64 ;
65
66 pub const Kind = enum { zsh, bash, fish, other };
67
68 pub fn detect(shell_path: []const u8) Kind {
69 const base = std.fs.path.basename(shell_path);
70 if (std.mem.eql(u8, base, "zsh")) return .zsh;
71 if (std.mem.eql(u8, base, "bash")) return .bash;
72 if (std.mem.eql(u8, base, "fish")) return .fish;
73 return .other;
74 }
75
76 pub const EnvPair = struct { key: [:0]const u8, value: [:0]const u8 };
77
78 /// Everything the spawn needs: the argv to exec and env pairs to set in
79 /// the child. `dir` must outlive the spawn (paths point into it).
80 pub const Injection = struct {
81 /// Extra argv AFTER the shell path (bash --init-file <shim>); empty
82 /// for env-only injections (zsh, fish) and for .other.
83 extra_argv: []const [:0]const u8,
84 env: []const EnvPair,
85 };
86
87 /// Prepare shim files under `dir` (created private, 0700) for `shell_path`
88 /// and return what spawn must add. All returned slices are allocated from
89 /// `arena` — hand it an arena that lives as long as the daemon.
90 pub fn prepare(
91 arena: std.mem.Allocator,
92 dir: []const u8,
93 shell_path: []const u8,
94 ) !Injection {
95 switch (detect(shell_path)) {
96 .zsh => {
97 try makeDirPrivate(dir);
98 const rc_path = try std.fs.path.join(arena, &.{ dir, ".zshrc" });
99 try writeFilePrivate(rc_path, zsh_zshrc);
100 var env: std.ArrayList(EnvPair) = .empty;
101 const dir_z = try arena.dupeZ(u8, dir);
102 try env.append(arena, .{ .key = "ZDOTDIR", .value = dir_z });
103 // Only when the daemon itself carried one: exporting an empty
104 // ZDOTDIR would break zsh's fallback to $HOME (spec footnote).
105 if (std.posix.getenv("ZDOTDIR")) |orig| {
106 try env.append(arena, .{
107 .key = "MUX_ORIG_ZDOTDIR",
108 .value = try arena.dupeZ(u8, orig),
109 });
110 }
111 return .{ .extra_argv = &.{}, .env = try env.toOwnedSlice(arena) };
112 },
113 .bash => {
114 try makeDirPrivate(dir);
115 const init_path = try std.fs.path.join(arena, &.{ dir, "bash-init.sh" });
116 try writeFilePrivate(init_path, bash_init);
117 const init_z = try arena.dupeZ(u8, init_path);
118 const argv = try arena.alloc([:0]const u8, 2);
119 argv[0] = "--init-file";
120 argv[1] = init_z;
121 return .{ .extra_argv = argv, .env = &.{} };
122 },
123 .fish => {
124 const vendor = try std.fs.path.join(arena, &.{ dir, "fish", "vendor_conf.d" });
125 try makeDirPrivate(vendor);
126 const conf_path = try std.fs.path.join(arena, &.{ vendor, "mux.fish" });
127 try writeFilePrivate(conf_path, fish_conf);
128 const orig = std.posix.getenv("XDG_DATA_DIRS") orelse "/usr/local/share:/usr/share";
129 const merged = try std.fmt.allocPrintSentinel(arena, "{s}:{s}", .{ dir, orig }, 0);
130 return .{
131 .extra_argv = &.{},
132 .env = try arena.dupe(EnvPair, &.{.{ .key = "XDG_DATA_DIRS", .value = merged }}),
133 };
134 },
135 .other => return .{ .extra_argv = &.{}, .env = &.{} },
136 }
137 }
138
139 /// makePath plus the 0700 tightening the key file's parent gets, and for
140 /// the same reason: the shim's contents are 0600 either way, but a 0755
141 /// directory publishes that this daemon exists and what it named its
142 /// files. Only the last component is tightened — the runtime directory on
143 /// the way there is not ours to re-permission.
144 fn makeDirPrivate(dir: []const u8) !void {
145 try std.fs.cwd().makePath(dir);
146 // `.iterate = true` is not optional: Dir.chmod fchmods the directory's
147 // own fd, and without it that fd is opened O_PATH, which fchmod refuses.
148 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
149 defer d.close();
150 try d.chmod(0o700);
151 }
152
153 fn writeFilePrivate(path: []const u8, contents: []const u8) !void {
154 const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600 });
155 defer f.close();
156 try f.writeAll(contents);
157 }
158
159 test "detect goes by basename" {
160 try std.testing.expectEqual(Kind.zsh, detect("/usr/bin/zsh"));
161 try std.testing.expectEqual(Kind.zsh, detect("zsh"));
162 try std.testing.expectEqual(Kind.bash, detect("/bin/bash"));
163 try std.testing.expectEqual(Kind.fish, detect("/usr/local/bin/fish"));
164 // The fallbacks: a POSIX sh and anything exotic get no injection at
165 // all, and the session runs on pgid + settle exactly as before.
166 try std.testing.expectEqual(Kind.other, detect("/bin/sh"));
167 try std.testing.expectEqual(Kind.other, detect("/usr/bin/nu"));
168 // A directory whose name matches must not be mistaken for the shell:
169 // basename of a trailing-slash path is the last component, not "".
170 try std.testing.expectEqual(Kind.other, detect("/opt/bash/bin/dash"));
171 }
172
173 /// The three prepare tests all want a real, writable, disposable directory
174 /// and the string naming it. No socket is bound here, so std's tmpDir (and
175 /// its long .zig-cache path) is fine — testtmp exists for sun_path, which
176 /// this module never touches.
177 const TmpPath = struct {
178 tmp: std.testing.TmpDir,
179 dir: []const u8,
180
181 fn make() !TmpPath {
182 var tmp = std.testing.tmpDir(.{});
183 errdefer tmp.cleanup();
184 const dir = try tmp.dir.realpathAlloc(std.testing.allocator, ".");
185 return .{ .tmp = tmp, .dir = dir };
186 }
187
188 fn deinit(self: *TmpPath) void {
189 std.testing.allocator.free(self.dir);
190 self.tmp.cleanup();
191 }
192 };
193
194 test "prepare zsh writes the shim and sets ZDOTDIR" {
195 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
196 defer arena.deinit();
197 var t = try TmpPath.make();
198 defer t.deinit();
199
200 const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
201 const inj = try prepare(arena.allocator(), shim, "/usr/bin/zsh");
202
203 // zsh is an env-only injection: the shell is exec'd with no extra argv
204 // and finds the shim because ZDOTDIR points at it.
205 try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
206 try std.testing.expect(inj.env.len >= 1);
207 try std.testing.expectEqualStrings("ZDOTDIR", inj.env[0].key);
208 try std.testing.expectEqualStrings(shim, inj.env[0].value);
209
210 // ZDOTDIR names the directory; the file zsh will source is the .zshrc
211 // inside it, which is the artifact worth asserting on.
212 const rc_path = try std.fs.path.join(arena.allocator(), &.{ inj.env[0].value, ".zshrc" });
213 const rc = try std.fs.cwd().readFileAlloc(std.testing.allocator, rc_path, 8192);
214 defer std.testing.allocator.free(rc);
215 // The two halves that make it work: the D mark carries the exit code,
216 // and the hooks are actually registered.
217 try std.testing.expect(std.mem.indexOf(u8, rc, "133;D;%s") != null);
218 try std.testing.expect(std.mem.indexOf(u8, rc, "add-zsh-hook") != null);
219 // ...and the shim hands control back to the user's own rc, which is the
220 // difference between integration and hijacking their shell.
221 try std.testing.expect(std.mem.indexOf(u8, rc, ".zshrc\"") != null);
222 }
223
224 test "prepare bash returns --init-file argv" {
225 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
226 defer arena.deinit();
227 var t = try TmpPath.make();
228 defer t.deinit();
229
230 const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
231 const inj = try prepare(arena.allocator(), shim, "/bin/bash");
232
233 // bash has no ZDOTDIR equivalent, so the shim arrives on the command
234 // line instead — and nothing goes into the environment.
235 try std.testing.expectEqual(@as(usize, 2), inj.extra_argv.len);
236 try std.testing.expectEqualStrings("--init-file", inj.extra_argv[0]);
237 try std.testing.expectEqual(@as(usize, 0), inj.env.len);
238
239 const script = try std.fs.cwd().readFileAlloc(std.testing.allocator, inj.extra_argv[1], 8192);
240 defer std.testing.allocator.free(script);
241 try std.testing.expect(std.mem.indexOf(u8, script, "PROMPT_COMMAND") != null);
242 try std.testing.expect(std.mem.indexOf(u8, script, "trap '_mux_preexec' DEBUG") != null);
243 // --init-file REPLACES ~/.bashrc, so the shim sourcing it is what keeps
244 // the user's shell theirs. Its absence would be silent.
245 try std.testing.expect(std.mem.indexOf(u8, script, "$HOME/.bashrc") != null);
246 }
247
248 test "prepare other injects nothing" {
249 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
250 defer arena.deinit();
251 var t = try TmpPath.make();
252 defer t.deinit();
253
254 const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
255 const inj = try prepare(arena.allocator(), shim, "/bin/sh");
256 try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
257 try std.testing.expectEqual(@as(usize, 0), inj.env.len);
258 // Not merely empty: an unknown shell must leave no trace on disk, so a
259 // /bin/sh session is byte-identical to one from before this module.
260 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(shim, .{}));
261 }
262
263 test "prepare zsh: the shim directory is 0700 and the rc file 0600" {
264 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
265 defer arena.deinit();
266 var t = try TmpPath.make();
267 defer t.deinit();
268
269 const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
270 _ = try prepare(arena.allocator(), shim, "/usr/bin/zsh");
271
272 // makePath alone leaves 0755. What lands here is a file the session
273 // shell sources — anyone who can write it can run code as this user —
274 // so the permissions are part of the contract, not decoration.
275 var d = try std.fs.cwd().openDir(shim, .{ .iterate = true });
276 defer d.close();
277 const dst = try d.stat();
278 try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(dst.mode & 0o777)));
279
280 const rc_path = try std.fs.path.join(arena.allocator(), &.{ shim, ".zshrc" });
281 const f = try std.fs.cwd().openFile(rc_path, .{});
282 defer f.close();
283 const fst = try f.stat();
284 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(fst.mode & 0o777)));
285 }