a73x

26643ada

refactor: no bare `mux run` — the v15 probe refuses before that exec could happen

a73x   2026-08-28 23:12

Commit message
refactor: no bare `mux run` — the v15 probe refuses before that exec could happen

The alias was the bridge for a daemon of v0.0.1-15 or older, whose upgrade
execs `<candidate> run --resume-fd N`. That exec never happens: the same
daemon runs `<candidate> --version` first and demands `muxd <version>`, which
this binary does not print, so it refuses the candidate before anything is
exec'd. An alias reachable only through a door that is already shut is a
second grammar for nothing.

So the rule is now without exception, and it is a value rather than a chain
of `if`s: `modeOf` answers `d`, `a`, `web` or the client, and `mux run` is a
host called "run" — pinned there, and pinned again in mux_main as the
refusal a user actually gets, which prints the page that lists the modes.

`main.zig`'s rollback exec went with it. It spelled bare `run` on the
argument that the binary being exec'd BACK is older; it is older only by what
this repo has shipped since, never by the rename, because a ≤v15 daemon can
never have been the writer of a manifest this binary is resuming.

`mux d upgrade` still translates that daemon's `version: output mismatch`
into the one-time `mux d stop` / `mux d start`.

CLAUDE.md
Old New
@@ -78,8 +78,9 @@ Layers are enforced in the same module table (grep `.layer =` for the graph).
78 78
79 ONE binary, `mux`, and the first word picks a mode: `mux d` the daemon, 79 ONE binary, `mux`, and the first word picks a mode: `mux d` the daemon,
80 `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no 80 `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no
81 letter at all the client. `mux run …` is the one alias — a v0.0.1-15 daemon's 81 letter at all the client. There is NO alias: a word that is not a mode is a
82 upgrade execs `<candidate> run --resume-fd N`. Module names in the table are 82 transport, so `mux run` names a host called "run" (pinned in `mux.zig`'s
83 `modeOf`). Module names in the table are
83 `daemon_main` `agent_main` `hub_main` `client_main`; the files keep their old 84 `daemon_main` `agent_main` `hub_main` `client_main`; the files keep their old
84 paths (`src/cli/main.zig` is the daemon). Test fixtures in `test/`: 85 paths (`src/cli/main.zig` is the daemon). Test fixtures in `test/`:
85 `ptyclient` (real client on a real pty), `wsclient` (browser stand-in), 86 `ptyclient` (real client on a real pty), `wsclient` (browser stand-in),
docs/decisions.md
Old New
@@ -7462,3 +7462,24 @@ binary rolled back TO is by definition the older one.
7462 Remote is unchanged in mechanism and changed in spelling: `handoff` builds 7462 Remote is unchanged in mechanism and changed in spelling: `handoff` builds
7463 `ssh HOST 'mux d endpoint'` and `'mux d start'`, so a box still running a 7463 `ssh HOST 'mux d endpoint'` and `'mux d start'`, so a box still running a
7464 ≤v0.0.1-15 install reads `[unreachable]` on the wall until it is upgraded. 7464 ≤v0.0.1-15 install reads `[unreachable]` on the wall until it is upgraded.
7465
7466 ## 2026-08-29 — the bare `mux run` alias is removed; it was unreachable
7467
7468 The entry above kept `mux run …` as the bridge for a daemon of v0.0.1-15 or
7469 older, whose upgrade execs `<candidate> run --resume-fd N`. That exec never
7470 happens: the same daemon runs `<candidate> --version` FIRST and demands
7471 `muxd <version>`, which this binary does not print, so it refuses the
7472 candidate before anything is exec'd. An alias reachable only through a door
7473 that is already shut is a second grammar for nothing, so the rule is now
7474 without exception — a word that is not `d`, `a` or `web` is a transport, and
7475 `mux run` names a host called "run" (`mux.zig`'s `modeOf`, and the refusal a
7476 user actually sees pinned in `mux_main.zig`).
7477
7478 `main.zig`'s rollback exec went with it: it spelled the bare `run` on the
7479 argument that the binary being exec'd BACK is older. It is older only by
7480 what this repo has shipped since, never by the rename, because a ≤v15 daemon
7481 can never have been the writer of a manifest this binary is resuming. It
7482 spells `d run` now, like every other exec here.
7483
7484 `mux d upgrade` still translates a `version: output mismatch` from that
7485 daemon into the one-time `mux d stop` / `mux d start`.
src/cli/main.zig
Old New
@@ -435,11 +435,11 @@ fn rollback(
435 435
436 var fd_buf: [12]u8 = undefined; 436 var fd_buf: [12]u8 = undefined;
437 const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{resume_fd}) catch return 1; 437 const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{resume_fd}) catch return 1;
438 // Bare `run`, not `d run`: the binary being exec'd back is by 438 // `d run`, the only spelling there is. The binary being exec'd back is
439 // definition OLDER than this one, and v0.0.1-15 and earlier know only 439 // older than this one, but not by more than the rename: a daemon of
440 // the bare spelling. This binary accepts both (mux.zig's bridge), so 440 // v0.0.1-15 or earlier refuses this binary as a candidate at its
441 // the older word is the one that works either way. 441 // version probe, so it can never have been the writer here.
442 const argv = [_:null]?[*:0]const u8{ "mux", "run", "--resume-fd", fd_str.ptr }; 442 const argv = [_:null]?[*:0]const u8{ "mux", "d", "run", "--resume-fd", fd_str.ptr };
443 const path_z = alloc.dupeZ(u8, writer_path) catch return 1; 443 const path_z = alloc.dupeZ(u8, writer_path) catch return 1;
444 const envp = rollbackEnvp(alloc) catch return 1; 444 const envp = rollbackEnvp(alloc) catch return 1;
445 445
src/cli/mux.zig
Old New
@@ -1,15 +1,28 @@
1 //! mux — the one binary. The first word picks a mode: `d` is the daemon, 1 //! mux — the one binary. The first word picks a mode: `d` is the daemon,
2 //! `a` the JSON agent surface, `web` the browser hub, and anything else 2 //! `a` the JSON agent surface, `web` the browser hub, and anything else
3 //! (including nothing) is the client. Each mode's own parser then reads the 3 //! (including nothing) is the client. Each mode's own parser then reads the
4 //! rest, so this file owns the mode letter and nothing else: there is no 4 //! rest, so this file owns the mode word and nothing else: there is no
5 //! second flag grammar here, and no argv[0] dispatch — the four programs 5 //! second flag grammar here, no argv[0] dispatch and no alias — the four
6 //! became four words, not four names for one file. 6 //! programs became four words, not four names for one file. A word that is
7 //! not a mode is a TARGET: `mux run` names a host called "run".
7 const std = @import("std"); 8 const std = @import("std");
8 const daemon = @import("daemon_main"); 9 const daemon = @import("daemon_main");
9 const agent = @import("agent_main"); 10 const agent = @import("agent_main");
10 const hub = @import("hub_main"); 11 const hub = @import("hub_main");
11 const client = @import("client_main"); 12 const client = @import("client_main");
12 13
14 /// The whole grammar, as a value, so the one decision this file makes can be
15 /// asked without a process to exit from.
16 const Mode = enum { daemon, agent, hub, client };
17
18 fn modeOf(args: []const [:0]const u8) Mode {
19 if (args.len < 2) return .client;
20 if (std.mem.eql(u8, args[1], "d")) return .daemon;
21 if (std.mem.eql(u8, args[1], "a")) return .agent;
22 if (std.mem.eql(u8, args[1], "web")) return .hub;
23 return .client;
24 }
25
13 pub fn main() !u8 { 26 pub fn main() !u8 {
14 var gpa: std.heap.DebugAllocator(.{}) = .init; 27 var gpa: std.heap.DebugAllocator(.{}) = .init;
15 defer if (gpa.deinit() == .leak) 28 defer if (gpa.deinit() == .leak)
@@ -19,23 +32,37 @@ pub fn main() !u8 {
19 const args = try std.process.argsAlloc(alloc); 32 const args = try std.process.argsAlloc(alloc);
20 defer std.process.argsFree(alloc, args); 33 defer std.process.argsFree(alloc, args);
21 34
22 if (args.len > 1) { 35 // Each named mode is handed a slice whose [0] is the word the user typed
23 // Each mode is handed a slice whose [0] is the word the user typed 36 // and whose [1..] is its own line — the shape every one of these parsers
24 // and whose [1..] is its own line — the shape every one of these 37 // already reads, from back when [0] was the program name. The client
25 // parsers already reads, from back when [0] was the program name. 38 // gets argv whole, because it has no word of its own to skip.
26 const rest = args[1..]; 39 return switch (modeOf(args)) {
27 if (std.mem.eql(u8, args[1], "d")) return daemon.main(rest); 40 .daemon => daemon.main(args[1..]),
28 if (std.mem.eql(u8, args[1], "a")) return agent.main(rest); 41 .agent => agent.main(args[1..]),
29 if (std.mem.eql(u8, args[1], "web")) return hub.main(rest); 42 .hub => hub.main(args[1..]),
30 // The bridge for daemons of v0.0.1-15 and older, and the only word 43 .client => client.main(args),
31 // that means a mode without naming one. Their upgrade execs the 44 };
32 // candidate as `<binary> run --resume-fd N` (the argv built in 45 }
33 // server.zig's upgradeExec), so a v15 daemon can only exec its way 46
34 // into this binary if bare `run` still reaches the daemon. Nothing 47 test "modeOf: the three mode words, and nothing else" {
35 // else is aliased: `mux stop` is a host called "stop". 48 try std.testing.expectEqual(Mode.daemon, modeOf(&.{ "mux", "d", "run" }));
36 if (std.mem.eql(u8, args[1], "run")) return daemon.main(args); 49 try std.testing.expectEqual(Mode.agent, modeOf(&.{ "mux", "a", "status" }));
37 } 50 try std.testing.expectEqual(Mode.hub, modeOf(&.{ "mux", "web" }));
38 return client.main(args); 51 try std.testing.expectEqual(Mode.client, modeOf(&.{"mux"}));
52 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "box" }));
53 }
54
55 test "modeOf: `run` is a host, not an alias for `d run`" {
56 // There WAS a bare `run` here, as the bridge for a daemon of v0.0.1-15
57 // or older exec'ing its upgrade candidate as `<binary> run --resume-fd
58 // N`. It is gone because it can never be reached: that daemon runs
59 // `<candidate> --version` first and demands `muxd <version>`, which this
60 // binary does not print, so it refuses the candidate before any exec.
61 // What is left is the rule with no exception — a word that is not a mode
62 // is a transport the user named, and `mux run --resume-fd 5` is refused
63 // by the client's own parser with the page that lists the modes.
64 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run" }));
65 try std.testing.expectEqual(Mode.client, modeOf(&.{ "mux", "run", "--resume-fd", "5" }));
39 } 66 }
40 67
41 test { 68 test {
src/cli/mux_main.zig
Old New
@@ -796,6 +796,14 @@ fn parseEnv(comptime argv: []const [:0]const u8, env: ?[]const u8) ParseError!Pa
796 return parseArgs(argv, env); 796 return parseArgs(argv, env);
797 } 797 }
798 798
799 test "parseArgs: `mux run --resume-fd N` is refused — there is no bare `run`" {
800 // The other half of mux.zig's `modeOf: run is a host` pin, and the half
801 // a user sees: with no alias, the word reaches this parser as a host
802 // spelling and the flag beside it is one no client has. The refusal
803 // prints `usage`, which opens with the four modes.
804 try std.testing.expectError(error.Usage, parse(&.{ "mux", "run", "--resume-fd", "5" }));
805 }
806
799 test "parseArgs: no arguments means the default local socket" { 807 test "parseArgs: no arguments means the default local socket" {
800 const r = try parse(&.{"mux"}); 808 const r = try parse(&.{"mux"});
801 try std.testing.expect(r == .attach); 809 try std.testing.expect(r == .attach);
src/server/server.zig
Old New
@@ -3583,9 +3583,7 @@ pub const Server = struct {
3583 3583
3584 // Build argv: {"mux", "d", "run", "--resume-fd", "<n>"}. The 3584 // Build argv: {"mux", "d", "run", "--resume-fd", "<n>"}. The
3585 // candidate is by definition NEWER than this binary, so it reads the 3585 // candidate is by definition NEWER than this binary, so it reads the
3586 // mode word; a v0.0.1-15 daemon exec'ing a newer one still sends the 3586 // mode word.
3587 // bare `run` its own copy of this line was written with, which is
3588 // why mux.zig keeps that spelling alive.
3589 var fd_buf: [12]u8 = undefined; 3587 var fd_buf: [12]u8 = undefined;
3590 const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{memfd}) catch return; 3588 const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{memfd}) catch return;
3591 const argv = [_:null]?[*:0]const u8{ 3589 const argv = [_:null]?[*:0]const u8{