a73x

src/cli/mux_main.zig

Ref:   Size: 55.6 KiB   History

//! Client mode for `mux`. `--sock PATH` attaches to a local daemon, `--via CMD`
//! uses the command's stdio without a shell, and a bare host uses the
//! SSH-to-QUIC handoff.
//!
//! Bare `mux` opens the wall the layout file names: one pane per leaf, in
//! the tree they were arranged in, and nothing else — a session born by
//! anyone else on a listed daemon is on no wall until a picker adds it.
//! Naming a transport opens the same wall zoomed on a pane for that
//! daemon's session `0`, adding it to both files if they did not have it.
//! This module handles argument validation, local daemon startup, and the
//! `hosts` subcommand before handing connections to the wall.
const std = @import("std");
const client = @import("client");
const proto = @import("term").protocol;
const build_options = @import("build_options");
const xdg = @import("xdg");
const spawn = @import("spawn");
const handoff = @import("client").handoff;
const sockpath = @import("sockpath");
const wall = @import("wall");
const hosts = @import("client").hosts;
const layoutfile = @import("client").layoutfile;
const cliflags = @import("cliflags");
const client_os = @import("client_os");
const TmpDir = @import("testtmp").TmpDir;

/// Root help page for the binary. It lists the mode words first, then documents
/// the default client mode; named modes provide their own `--help` pages.
const usage =
    \\usage: mux [TARGET ...]    attach, or the wall (this page)
    \\       mux d VERB ...     the daemon: run start stop stats dump proxy
    \\                          endpoint keygen upgrade   (`mux d --help`)
    \\       mux a VERB ...     the agent surface, one JSON object per verb
    \\                          (`mux a --help`)
    \\       mux web [TARGET ...]  the browser hub   (`mux web --help`)
    \\
    \\  mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]]
    \\  HOST attaches over ssh and hands off to QUIC when the daemon offers it
    \\  (`mux` must be on HOST's PATH; cached coordinates make later attaches
    \\  skip ssh entirely)
    \\  quic://HOST[:PORT] (PORT defaults to 4433) uses --key FILE,
    \\  MUX_KEY_FILE, or ~/.config/mux/key; the daemon must be running with a
    \\  matching --quic and key
    \\  --via CMD is argv words, exec'd directly: no shell, no quoting
    \\  [--quic-idle-ms N] tunes how fast a dead link is noticed
    \\  [--session NAME] attaches to (or creates) a named session instead of
    \\  the default (`0`); NAME is printable ASCII, no space, no '#' or '/'
    \\  -A forwards this client's ssh-agent into the session, like ssh -A:
    \\  whoever typed last is whose agent signs, and only while attached
    \\  --version prints the version, --help prints this
    \\
    \\  mux                         the wall: the panes your layout file names
    \\  mux -A                      the same wall, entered on the local session with the
    \\                              agent armed — bare `mux` carries no agent
    \\  mux hosts                   list the daemons you can browse, with their live session counts
    \\  mux hosts add SPELLING      record a daemon without opening it
    \\  mux hosts rm SPELLING       take one off (its sessions keep running)
    \\
    \\  SPELLING names a DAEMON — HOST, quic://HOST[:PORT], or --sock PATH
    \\  (one argument, or two as in `mux web`) — and never a session: `#NAME`
    \\  is refused, because a host line names a machine and the layout file
    \\  is what names sessions.
    \\
    \\  On the wall, `Ctrl-\ 1-9` focuses a pane and types into it,
    \\  `Ctrl-\ n/p` walk the panes, `Ctrl-\ h/j/k/l` moves between them,
    \\  `Ctrl-\ c` and `Ctrl-\ |/-` create a session on the focused pane's
    \\  daemon, `Ctrl-\ x` takes the focused pane off the wall (the session
    \\  keeps running), `Ctrl-\ f` fullscreen, `Ctrl-\ r` resize mode,
    \\  `Ctrl-\ w` zooms out, `Ctrl-\ d` leaves.
    \\
    \\  `Ctrl-\ s` opens the picker, which is how a session joins the wall:
    \\  every DAEMON with what its last poll said, Enter for that daemon's
    \\  sessions, Enter again to add one as a pane. j/k or the arrows move,
    \\  1-9 pick a row, c starts a new session, x forgets a host or ends a
    \\  session, a adds a host by spelling, Esc backs out.
    \\
;

/// A successfully parsed connection specification.
///
/// Keeping parsing separate from printing and process exit makes it easy to
/// test. Invalid usage, conflicting transports, `--help`, and `--version`
/// are returned as `ParseError` values instead.
const ConnectionSpec = union(enum) {
    /// Attach through a Unix socket or a command supplied with `--via`.
    /// If both are null, use the default local socket.
    ///
    /// An empty session name preserves the original wire encoding for the
    /// default session; see `encodeAttachNamed`.
    attach: struct {
        sock: ?[]const u8 = null,
        via: ?[]const u8 = null,
        session: []const u8 = "",
        agent: bool = false,
    },

    /// Connect to a bare hostname through SSH.
    ///
    /// `main` builds the SSH command because doing so requires an allocator.
    /// The resulting handoff uses QUIC, so its idle timeout is retained here.
    host: struct {
        name: []const u8,
        idle_ms: u32,
        session: []const u8 = "",
        agent: bool = false,
    },

    /// Connect directly to a `quic://HOST[:PORT]` endpoint.
    ///
    /// `key` is optional here because `main` can resolve the default key using
    /// the environment and filesystem.
    quic: struct {
        host_port: []const u8,
        key: ?[]const u8,
        idle_ms: u32,
        session: []const u8 = "",
        agent: bool = false,
    },
};

/// Extend the shared CLI errors with the case where more than one transport is
/// specified.
const ParseError = cliflags.ParseError || error{Conflict};

/// ssh-agent frame for `SSH_AGENTC_REQUEST_IDENTITIES`, the same request used by
/// `ssh-add -l`. Any protocol reply proves that an agent is present. Agent
/// forwarding remains byte-transparent outside this client-side probe.
const agent_request_identities = [_]u8{ 0, 0, 0, 1, 11 };

/// Maximum wait for the agent probe. The daemon immediately closes a forwarded
/// agent socket when no client is offering an agent; a slow reply may still come
/// from a real agent or hardware token.
const agent_probe_ms = 500;

/// Probe whether `path` leads to an ssh-agent by sending an identities request.
/// A connect alone is insufficient inside a mux session because the daemon
/// accepts the socket before checking for an offering client. EOF means no
/// agent is available; a timeout is treated as reachable because real agents
/// and hardware tokens may reply slowly.
fn agentReachable(path: []const u8) bool {
    const fd = client.connectAgent(path) orelse return false;
    defer std.posix.close(fd);

    // Through `client_os.sendNoSig` because this probe runs before the client
    // installs signal handling and the peer may already have closed the socket;
    // the operation's contract is that a closed peer comes back as an error
    // rather than as a signal.
    _ = client_os.sendNoSig(fd, &agent_request_identities) catch return false;

    var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }};
    const ready = std.posix.poll(&pfd, agent_probe_ms) catch return true;
    if (ready == 0) return true;
    var reply: [1]u8 = undefined;
    // EOF after a successful connect is how the daemon reports that no attached
    // client is offering an agent.
    const n = std.posix.read(fd, &reply) catch return false;
    return n != 0;
}

/// Build the diagnostic from `proto.session_env` so it stays synchronized with
/// the environment variable set for sessions.
const self_attach_refusal =
    "mux: this shell is inside that session (unset " ++ proto.session_env ++ " to override)\n";

/// Raw client arguments. Field names and types define flag syntax; `parseArgs`
/// performs cross-field and transport validation.
const ClientArguments = struct {
    sock: ?[]const u8 = null,
    via: ?[]const u8 = null,
    key: ?[]const u8 = null,
    /// Optional so validation applies only to an explicitly supplied name. An
    /// empty string is reserved for the wire encoding of the default session.
    session: ?proto.SessionName = null,
    quic_idle_ms: client.IdleMs = .{},
    agent: bool = false,
    /// Leading underscores exclude these parser bookkeeping fields from flag
    /// generation. They are populated by `positional`.
    _host: ?[]const u8 = null,
    _quic: ?[]const u8 = null,
    _targets: u8 = 0,

    pub const aliases = .{.{ "-A", "agent" }};

    /// Parse a bare hostname or `quic://...` target. Count every positional
    /// target so `parseArgs` can reject multiple transports. An empty
    /// `quic://` target is invalid immediately.
    pub fn positional(self: *ClientArguments, word: []const u8) bool {
        if (std.mem.startsWith(u8, word, hosts.quic_prefix)) {
            const host_port = word[hosts.quic_prefix.len..];
            if (host_port.len == 0) return false;
            self._quic = host_port;
        } else self._host = word;
        self._targets += 1;
        return true;
    }
};

comptime {
    cliflags.assertDocumented(ClientArguments, usage, &.{});
}

fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseError!ConnectionSpec {
    var o: ClientArguments = .{};
    try cliflags.parseStrict(ClientArguments, &o, args[1..]);

    // At most one socket, via command, hostname, or QUIC endpoint may be named.
    // Counting positional targets also detects two bare host arguments.
    const named: u8 = @as(u8, @intFromBool(o.sock != null)) +
        @intFromBool(o.via != null) + o._targets;
    if (named > 1) return error.Conflict;

    // Session selection and agent forwarding apply to every transport.
    const session = if (o.session) |n| n.name else "";

    if (o._quic) |host_port| {
        // A missing explicit key is valid because `main` can resolve the
        // environment or default path using the filesystem.
        return .{ .quic = .{
            .host_port = host_port,
            .key = xdg.pickKey(o.key, env_key),
            .idle_ms = o.quic_idle_ms.ms,
            .session = session,
            .agent = o.agent,
        } };
    }
    // Ignore key configuration for non-QUIC transports. In particular, an
    // exported `MUX_KEY_FILE` must not break local or SSH connections.
    if (o._host) |h| return .{ .host = .{ .name = h, .idle_ms = o.quic_idle_ms.ms, .session = session, .agent = o.agent } };
    return .{ .attach = .{ .sock = o.sock, .via = o.via, .session = session, .agent = o.agent } };
}

/// Run the client mode. Unlike named modes, it receives the complete argv from
/// the top-level dispatcher.
pub fn main(args: []const [:0]const u8) !u8 {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer if (gpa.deinit() == .leak)
        std.debug.print("mux: LEAK: allocations outlived deinit\n", .{});
    const alloc = gpa.allocator();

    // Dispatch `hosts` before transport parsing because it operates on the
    // state file and never opens a session.
    if (args.len > 1 and std.mem.eql(u8, args[1], "hosts"))
        return hostsMain(alloc, args[2..], null);

    // Bare `mux` opens the wall, while an explicit default socket opens an
    // attachment even though both would produce the same `ConnectionSpec`.
    if (args.len == 1) return wallOfHosts(alloc);

    const parsed = parseArgs(args, std.posix.getenv(xdg.key_env)) catch |e| switch (e) {
        error.Conflict => {
            std.debug.print(
                "mux: name one transport: HOST, --sock, --via or quic://\n{s}",
                .{usage},
            );
            return 2;
        },
        else => |pe| return cliflags.exitFor(pe, usage, "mux", build_options.version),
    };

    // Validate `-A` before dialing. Advertising an unavailable agent would make
    // the daemon route agent requests to a client that cannot answer them.
    const wants_agent = switch (parsed) {
        .host => |h| h.agent,
        .quic => |q| q.agent,
        .attach => |at| at.agent,
    };
    if (wants_agent) {
        const sock = std.posix.getenv(proto.agent_sock_env) orelse "";
        if (!agentReachable(sock)) {
            if (sock.len == 0) {
                std.debug.print(
                    "mux: -A: " ++ proto.agent_sock_env ++ " is not set — no ssh-agent to forward\n",
                    .{},
                );
            } else {
                std.debug.print(
                    "mux: -A: no ssh-agent answering at {s}\n",
                    .{sock},
                );
            }
            return 2;
        }
    }

    switch (parsed) {
        .quic => |q| {
            const res = try xdg.resolveKeyPath(alloc, q.key);
            defer res.deinit(alloc);
            const key_path = switch (res) {
                .given, .default => |p| p,
                .missing => |p| {
                    std.debug.print(
                        "mux: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen` (default {s})\n",
                        .{p},
                    );
                    return 2;
                },
            };
            return wall.runAttach(alloc, .{ .quic = .{
                .host_port = q.host_port,
                .key_path = key_path,
                .idle_ms = q.idle_ms,
            } }, q.session, q.key, q.idle_ms, q.agent, build_options.version);
        },
        .host => |h| {
            // The handoff recipe uses SSH to obtain coordinates and as a
            // fallback transport. A cached endpoint can skip SSH entirely.
            const r = try handoff.recipeFor(alloc, h.name, false);
            defer r.deinit(alloc);
            // A direct `mux HOST` invocation may start the remote daemon and
            // report fallback progress. The host picker's Enter path does the
            // same.
            var target = client.HandoffTarget.fromRecipe(h.name, r, h.idle_ms, true);
            // Relay SSH stderr for this foreground attach; no alternate-screen
            // UI is active yet.
            target.narrate = true;
            return wall.runAttach(alloc, .{ .hand = target }, h.session, null, h.idle_ms, h.agent, build_options.version);
        },
        .attach => |t| {
            if (t.via) |cmd| return wall.runAttach(
                alloc,
                .{ .via = cmd },
                t.session,
                null,
                client.quic_idle_ms_default,
                t.agent,
                build_options.version,
            );
            const sock_path = if (t.sock) |s|
                try alloc.dupe(u8, s)
            else
                try sockpath.defaultOrExplain(alloc, "mux") orelse return 1;
            defer alloc.free(sock_path);
            return attachLocal(alloc, sock_path, t.session, t.agent);
        },
    }
}

/// The reason the local socket needs a start — the dial's own error — when
/// the hosts file lists it and nothing answers on it; null otherwise. The
/// reason rather than a bool because `startLocalDaemon` writes it down.
fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) ?anyerror {
    var buf: [std.fs.max_path_bytes + hosts.sock_prefix.len]u8 = undefined;
    const line = std.fmt.bufPrint(&buf, hosts.sock_prefix ++ "{s}", .{sock}) catch return null;
    if (!h.has(line)) return null;
    return sockpath.probe(sock);
}

/// Attach through a local Unix socket, used by both `mux --sock PATH` and the
/// empty-hosts-file fallback.
fn attachLocal(
    alloc: std.mem.Allocator,
    sock_path: []const u8,
    session: []const u8,
    agent: bool,
) !u8 {
    // Reject attaching a shell to the same session it already occupies. This
    // check runs after resolving the default socket path and applies only to a
    // direct client attach; wall-created tiles do not call this function.
    if (wall.showsSelf(
        .{ .sock = sock_path },
        session,
        std.posix.getenv(proto.sock_env),
        std.posix.getenv(proto.session_env),
    )) {
        std.debug.print("{s}", .{self_attach_refusal});
        return 2;
    }

    if (sockpath.probe(sock_path)) |why| {
        if (!try startLocalDaemon(alloc, sock_path, why)) return 1;
    }
    return wall.runAttach(
        alloc,
        .{ .sock = sock_path },
        session,
        null,
        client.quic_idle_ms_default,
        agent,
        build_options.version,
    );
}

/// Start a detached local daemon by executing this binary as
/// `mux d start -d --sock PATH`. Inherited stdio preserves daemon diagnostics.
///
/// `why` is what the dial answered, and it goes into the daemon log FIRST:
/// `FileNotFound` is a path with nothing at it, `ConnectionRefused` a
/// socket file nobody is listening on. When a running daemon's socket file
/// is deleted, this is the line that says a second daemon was started on
/// the path, when, and that the path was empty rather than dead at the
/// time — none of which the 2026-09-04 log could say (issue 04b3019d).
fn startLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8, why: anyerror) !bool {
    const note = try std.fmt.allocPrint(
        alloc,
        "mux: auto-starting a daemon on {s}: the dial said {s}\n",
        .{ sock_path, @errorName(why) },
    );
    defer alloc.free(note);
    xdg.appendLogLine(alloc, note) catch {};
    var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
    const argv = [_][]const u8{ spawn.selfExe(&exe_buf), "d", "start", "-d", "--sock", sock_path };
    var child = std.process.Child.init(&argv, alloc);
    const term = child.spawnAndWait() catch |err| {
        std.debug.print("mux: could not run {s}: {s}\n", .{ argv[0], @errorName(err) });
        return false;
    };
    return term == .Exited and term.Exited == 0;
}

/// Parsed host arguments for `mux hosts add`. Every positional word is a host
/// spelling; this command defines no independent flags.
const HostsArguments = struct {
    _argv: hosts.Argv,

    pub fn positional(self: *HostsArguments, w: []const u8) bool {
        return self._argv.positional(w);
    }
    pub fn extra(self: *HostsArguments, rest: []const [:0]const u8) usize {
        return self._argv.extra(rest);
    }
};

comptime {
    cliflags.assertDocumented(HostsArguments, usage, &.{});
}

/// Open the daemon wall. An empty hosts file falls back to the local socket.
/// Resolved host data uses an arena because `wall.run` does not return on
/// success.
fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
    var arena_state = std.heap.ArenaAllocator.init(alloc);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const path = try hosts.statePath(arena);
    const h = hosts.load(arena, path) catch |err| return refuseFile(arena, "mux", path, err);
    if (h.lines.items.len == 0) {
        // On first use, attach to the default local daemon. `wall.runAttach`
        // records the socket only after the connection succeeds.
        const sock_path = try sockpath.defaultOrExplain(arena, "mux") orelse return 1;
        return attachLocal(alloc, sock_path, "", false);
    }

    // Restart a listed local daemon after reboot when its persistent hosts-file
    // entry remains but its socket is inactive. If startup fails, wall polling
    // continues and can discover a daemon started by another process.
    if (sockpath.defaultSockPath(arena) catch null) |sock| {
        if (localNeedsStart(&h, sock)) |why| _ = try startLocalDaemon(alloc, sock, why);
    }

    const key = std.posix.getenv(xdg.key_env);
    const specs = try arena.alloc(wall.HostSpec, h.lines.items.len);
    for (specs, h.lines.items) |*s, line| {
        // Fail the entire wall when a persisted host cannot be parsed; silently
        // omitting an explicitly configured daemon would misrepresent the file.
        s.* = wall.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| {
            std.debug.print("mux: bad host '{s}': {s}\n", .{ line, hosts.reason(err) });
            return 2;
        };
    }
    return wall.run(arena, specs, .{ .key = key, .own_version = build_options.version });
}

/// Timeout for a daemon's session-count reply. SSH setup is intentionally not
/// covered because it may wait for interactive authentication.
const hosts_list_ms = 2000;

/// `mux hosts [add|rm SPELLING...]`; a null `state_path` is the real file.
fn hostsMain(
    alloc: std.mem.Allocator,
    args: []const [:0]const u8,
    state_path: ?[]const u8,
) !u8 {
    var arena_state = std.heap.ArenaAllocator.init(alloc);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const path = state_path orelse try hosts.statePath(arena);

    if (args.len == 0) return hostsList(arena, path, std.posix.STDOUT_FILENO);
    // Help and version take precedence over verb validation, matching the
    // shared flag parser and avoiding a misleading unknown-verb diagnostic.
    for (args) |a| {
        if (cliflags.isHelp(a)) return cliflags.help(usage);
        if (cliflags.isVersion(a)) return cliflags.version("mux", build_options.version);
    }
    const adding = std.mem.eql(u8, args[0], "add");
    if (!adding and !std.mem.eql(u8, args[0], "rm")) {
        std.debug.print("mux hosts: add or rm, not '{s}'\n{s}", .{ args[0], usage });
        return 2;
    }
    return hostsEdit(arena, adding, args[1..], path);
}

/// Print each configured host with its current live-session count. Counts come
/// from the daemon because the hosts file contains no session state.
fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t) !u8 {
    // Load lines verbatim so invalid hand-edited entries remain visible and can
    // be passed back to `mux hosts rm`. This read-only operation does not risk
    // rewriting unrecognized content.
    const lines = hosts.loadLines(arena, path) catch |err| return refuseFile(arena, "mux hosts", path, err);
    const key = std.posix.getenv(xdg.key_env);
    for (lines.items) |line| {
        const spec = wall.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| {
            printRow(out_fd, line, "\t[bad host: {s}]\n", .{hosts.reason(err)});
            continue;
        };
        var out: [proto.sessions_reply_max]u8 = undefined;
        const list = client.listSessions(arena, spec.poll_target, &out, hosts_list_ms, null, null) catch |err| {
            // Propagate local allocation failure rather than reporting the
            // remote daemon as unreachable.
            if (err == error.OutOfMemory) return err;
            printRow(out_fd, line, "\t[unreachable]\n", .{});
            continue;
        };
        printRow(out_fd, line, "\t{d}\n", .{countSessions(list)});
    }
    return 0;
}

/// Print one hosts-file line verbatim followed by its formatted status.
fn printRow(fd: std.posix.fd_t, line: []const u8, comptime fmt: []const u8, args: anytype) void {
    // Write the host separately because lines may be up to one MiB while the
    // status buffer is small. Truncating a line would make it impossible to
    // copy that exact value into `mux hosts rm`.
    proto.writeAllFd(fd, line) catch return;
    printOut(fd, fmt, args);
}

/// Write requested listing output to the supplied stdout-like descriptor;
/// diagnostics remain on stderr.
fn printOut(fd: std.posix.fd_t, comptime fmt: []const u8, args: anytype) void {
    // An explicit descriptor lets tests capture output without interfering
    // with the test runner's stdout protocol.
    var buf: [512]u8 = undefined;
    const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
    proto.writeAllFd(fd, s) catch {};
}

/// Report a hosts-file failure. Parse errors print the invalid lines and return
/// exit code 2; I/O and allocation failures return exit code 1.
fn refuseFile(arena: std.mem.Allocator, who: []const u8, path: []const u8, err: anyerror) u8 {
    std.debug.print("{s}: {s}: {s}\n", .{ who, path, hosts.reason(err) });
    // Classify the original error before reading the file again. Otherwise an
    // allocation failure in a file that also contains invalid syntax could be
    // misreported as a user-correctable parse error.
    if (!hosts.isParse(err)) return 1;
    const lines = hosts.loadLines(arena, path) catch return 2;
    for (lines.items) |l| {
        _ = hosts.parse(l) catch std.debug.print("  {s}\n", .{l});
    }
    return 2;
}

/// Count the session names in a `sessions_reply`, with or without a trailing
/// newline. `proto.sessionsIter` skips blank lines and anything that is not a
/// spellable name, so the number printed beside a host is the number of
/// sessions a wall would actually put on screen for it.
fn countSessions(list: []const u8) usize {
    var n: usize = 0;
    var it = proto.sessionsIter(list);
    while (it.next()) |_| n += 1;
    return n;
}

/// Add or remove host spellings in one state-file update. Neither operation
/// connects to a daemon.
fn hostsEdit(
    arena: std.mem.Allocator,
    adding: bool,
    args: []const [:0]const u8,
    path: []const u8,
) !u8 {
    const verb = if (adding) "add" else "rm";
    var spellings: std.ArrayList([]const u8) = .empty;

    if (adding) {
        // Validate new entries while their command-line spelling is available
        // so diagnostics can identify the exact invalid target.
        var o = HostsArguments{ ._argv = .{ .alloc = arena } };
        const outcome = cliflags.parse(HostsArguments, &o, args);
        // Prefer the host parser's specific recorded error over the generic
        // unknown-argument result returned when its hook rejects a word.
        if (o._argv.err) |e| {
            if (e.err == error.OutOfMemory) return e.err;
            std.debug.print("mux hosts add: '{s}': {s}\n", .{ e.word, hosts.reason(e.err) });
            return 2;
        }
        switch (outcome) {
            .ok => {},
            .help => return cliflags.help(usage),
            .version => return cliflags.version("mux", build_options.version),
            .missing_value, .bad_value => {
                std.debug.print("{s}", .{usage});
                return 2;
            },
            .unknown_arg => |a| {
                std.debug.print("mux hosts add: takes hosts, not flags: '{s}'\n{s}", .{ a, usage });
                return 2;
            },
        }
        spellings = o._argv.list;
    } else {
        // Parse only argv grouping for `rm`; do not validate host syntax. This
        // allows removal of hand-edited lines that the current grammar rejects.
        var i: usize = 0;
        while (i < args.len) : (i += 1) {
            const n = hosts.spellingFromArgv(arena, args, i) catch |err| switch (err) {
                error.MissingSockPath => {
                    std.debug.print("mux hosts rm: '--sock' names no path\n", .{});
                    return 2;
                },
                error.FlagLikeTarget => {
                    std.debug.print("mux hosts rm: '{s}' is a flag, not a host\n", .{args[i]});
                    return 2;
                },
                else => |e| return e,
            };
            i += n.consumed - 1;
            try spellings.append(arena, n.spelling);
        }
    }
    if (spellings.items.len == 0) {
        std.debug.print("mux hosts {s}: name at least one host\n", .{verb});
        return 2;
    }

    if (adding) return hostsAdd(arena, spellings.items, path);

    // Apply all removals in one read-modify-write so an I/O error cannot leave
    // only a prefix of the requested changes committed.
    const gone = try arena.alloc(bool, spellings.items.len);
    @memset(gone, false);
    hosts.forgetMany(arena, path, spellings.items, gone) catch |err| {
        std.debug.print("mux hosts rm: {s}: {s}\n", .{ path, hosts.reason(err) });
        return 1;
    };
    var rc: u8 = 0;
    for (spellings.items, gone) |s, g| {
        // A missing entry makes the command nonzero so scripts do not mistake a
        // differently spelled host for a successful removal. Other removals
        // still apply.
        if (!g) {
            std.debug.print("mux hosts rm: not on the wall: {s}\n", .{s});
            rc = 1;
        }
    }
    // The panes go with the daemon. A layout leaf is `HOST#SESSION` and its
    // host part must be a line of the hosts file, so a leaf left behind here
    // refuses the WHOLE layout at the next start — one `mux hosts rm box`
    // and the user's other four panes are gone with a printed line they did
    // not ask for. The sessions themselves keep running; this is the file,
    // not the daemon. `wall_picker.pickForget` is the same edit typed from
    // inside a wall, made there by the wall's own save.
    if (layoutBeside(arena, path)) |lp| {
        if (layoutfile.forgetHosts(arena, lp, spellings.items, "mux hosts")) |_| {} else |err| {
            // Not a failure of the removal: the hosts file is already
            // written and the daemon is already off the wall. Said, because
            // the next `mux` will refuse a layout this could not repair.
            std.debug.print("mux hosts rm: {s}: {s}\n", .{ lp, hosts.reason(err) });
        }
    }
    // `forgetMany` matches exact lines. On failure, print the file so a
    // hand-edited entry can be copied byte for byte.
    if (rc != 0) showFile(arena, path);
    return rc;
}

/// The layout file beside a given hosts file. Both are `xdg.statePath` names
/// in one directory (`hosts.statePath`, `hosts.layoutPath`), so the sibling
/// is the layout for THIS hosts file — including the tmp-directory pair a
/// test drives, which an `XDG_STATE_HOME` lookup here would miss.
fn layoutBeside(arena: std.mem.Allocator, hosts_path: []const u8) ?[]const u8 {
    const dir = std.fs.path.dirname(hosts_path) orelse return null;
    return std.fs.path.join(arena, &.{ dir, "layout" }) catch null;
}

/// Print the hosts file verbatim for use with an exact-match removal.
fn showFile(arena: std.mem.Allocator, path: []const u8) void {
    const lines = hosts.loadLines(arena, path) catch return;
    for (lines.items) |l| std.debug.print("  {s}\n", .{l});
}

/// Validate every spelling before saving all additions in one write, preventing
/// partial updates when validation or I/O fails.
fn hostsAdd(arena: std.mem.Allocator, spellings: []const []const u8, path: []const u8) !u8 {
    for (spellings) |s| {
        const spec = hosts.parse(s) catch |err| {
            std.debug.print("mux hosts add: {s}: {s}\n", .{ s, hosts.reason(err) });
            return 2;
        };
        // Reject Unix socket paths that cannot fit in `sun_path`; such an entry
        // could never be dialed successfully.
        if (spec == .sock and sockpath.tooLong("mux hosts add", spec.sock)) return 2;
    }
    // Refuse to extend a file containing unrecognized entries because saving it
    // would legitimize or alter content the parser did not understand.
    var h = hosts.load(arena, path) catch |err|
        return refuseFile(arena, "mux hosts add", path, err);
    // Adding an existing host is idempotent.
    for (spellings) |s| _ = try h.add(arena, s);
    hosts.save(&h, path) catch |err| {
        std.debug.print("mux hosts add: {s}: {s}\n", .{ path, hosts.reason(err) });
        return 1;
    };
    return 0;
}

/// Test helper using the sentinel-terminated argv shape consumed by `parseArgs`.
fn parse(comptime argv: []const [:0]const u8) ParseError!ConnectionSpec {
    return parseArgs(argv, null);
}

/// The same, with `MUX_KEY_FILE` set to `env`.
fn parseEnv(comptime argv: []const [:0]const u8, env: ?[]const u8) ParseError!ConnectionSpec {
    return parseArgs(argv, env);
}

test "parseArgs: `mux run --resume-fd N` is rejected because bare `run` is a host" {
    // The dispatcher treats `run` as a hostname, leaving `--resume-fd` as an
    // unknown client flag. This complements the dispatcher-level test.
    try std.testing.expectError(error.Usage, parse(&.{ "mux", "run", "--resume-fd", "5" }));
}

test "parseArgs: no arguments means the default local socket" {
    const r = try parse(&.{"mux"});
    try std.testing.expect(r == .attach);
    try std.testing.expect(r.attach.sock == null);
    try std.testing.expect(r.attach.via == null);
}

test "parseArgs: --sock and --via each name their transport" {
    const s = try parse(&.{ "mux", "--sock", "/tmp/x.sock" });
    try std.testing.expect(s == .attach);
    try std.testing.expectEqualStrings("/tmp/x.sock", s.attach.sock.?);
    try std.testing.expect(s.attach.via == null);

    const v = try parse(&.{ "mux", "--via", "ssh box mux d proxy" });
    try std.testing.expect(v == .attach);
    try std.testing.expectEqualStrings("ssh box mux d proxy", v.attach.via.?);
    try std.testing.expect(v.attach.sock == null);
}

test "parseArgs: a bare word is a host to hop to" {
    const h = try parse(&.{ "mux", "vm1" });
    try std.testing.expect(h == .host);
    try std.testing.expectEqualStrings("vm1", h.host.name);
    // Use the documented literal so this test detects an accidental change to
    // the parser's default constant.
    try std.testing.expectEqual(@as(u32, 15_000), h.host.idle_ms);

    // Preserve `user@host` as an opaque SSH hostname so SSH configuration such
    // as aliases, ports, and ProxyJump continues to apply.
    const u = try parse(&.{ "mux", "ubuntu@sandbox-9b70e9" });
    try std.testing.expect(u == .host);
    try std.testing.expectEqualStrings("ubuntu@sandbox-9b70e9", u.host.name);
}

test "parseArgs: naming two transports is a conflict, however it is spelled" {
    try std.testing.expectError(error.Conflict, parse(&.{ "mux", "vm1", "--sock", "/tmp/x.sock" }));
    try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/tmp/x.sock", "vm1" }));
    try std.testing.expectError(error.Conflict, parse(&.{ "mux", "vm1", "--via", "ssh box mux d proxy" }));
    try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/a", "--via", "c" }));
    // Two positional targets conflict just like two different transports.
    // Repeating a flag is distinct: the shared parser keeps its last value.
    try std.testing.expectError(error.Conflict, parse(&.{ "mux", "vm1", "vm2" }));
    try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "quic://b:2" }));
    const s2 = try parse(&.{ "mux", "--sock", "/a", "--sock", "/b" });
    try std.testing.expectEqualStrings("/b", s2.attach.sock.?);
    const v2 = try parse(&.{ "mux", "--via", "ssh a", "--via", "ssh b" });
    try std.testing.expectEqualStrings("ssh b", v2.attach.via.?);
}

test "hosts: add refuses a session by name, rm reports an unlisted host, the file keeps add order" {
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [256]u8 = undefined;
    // Use the production directory shape and verify that `add` creates a
    // missing parent directory on first use.
    const path = try std.fmt.bufPrint(&buf, "{s}/mux/hosts", .{tmp.path()});

    // Reject a session suffix before writing any state.
    try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "box#build" }, path));
    // A flag-shaped word is not a host, and `--sock PATH`'s two words are
    // one spelling.
    try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "-A" }, path));
    try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "box" }, path));
    try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "--sock", "/tmp/x.sock" }, path));
    // A missing removal target returns nonzero so scripts can detect a spelling
    // mismatch.
    try std.testing.expectEqual(@as(u8, 1), try hostsMain(alloc, &[_][:0]const u8{ "rm", "nowhere" }, path));

    var h = try hosts.load(alloc, path);
    defer h.deinit(alloc);
    try std.testing.expectEqual(@as(usize, 2), h.lines.items.len);
    try std.testing.expectEqualStrings("box", h.lines.items[0]);
    try std.testing.expectEqualStrings("--sock /tmp/x.sock", h.lines.items[1]);

    try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "rm", "box" }, path));
    var after = try hosts.load(alloc, path);
    defer after.deinit(alloc);
    try std.testing.expectEqual(@as(usize, 1), after.lines.items.len);
    try std.testing.expectEqualStrings("--sock /tmp/x.sock", after.lines.items[0]);

    // Neither verb is a subcommand this program has.
    try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{"list"}, path));

    // `test/e2e_09_hosts.sh` covers `mux hosts --help`; invoking it here would
    // write the help page to the test runner's stdout protocol.
}

test "hosts: a line the file cannot hold exits 2 wherever the file is read" {
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [256]u8 = undefined;
    const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
    // Include a hand-edited invalid entry to verify that `rm` can repair state
    // the current grammar cannot parse.
    try hosts.saveBytes(path, "--sock /tmp/x.sock\nbox#old\n");

    // Both adding and loading reject the invalid file with exit code 2. Removal
    // reads verbatim, repairs the file, and then additions may resume.
    try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path));
    try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "rm", "box#old" }, path));
    try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path));
}

test "hosts rm: the removed daemon's panes leave the layout beside the hosts file" {
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [256]u8 = undefined;
    const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
    var lbuf: [256]u8 = undefined;
    const lpath = try std.fmt.bufPrint(&lbuf, "{s}/layout", .{tmp.path()});
    try hosts.saveBytes(path, "box\n--sock /tmp/a.sock\n");
    // Two hosts, three panes: the removal must take the two that name `box`
    // and leave the third exactly where it was.
    try hosts.saveBytes(lpath,
        \\mux-layout 1
        \\beside 0
        \\ leaf 60 box#0
        \\ stacked 40
        \\  leaf 50 --sock /tmp/a.sock#work
        \\  leaf 50 box#two
        \\
    );
    try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "rm", "box" }, path));
    const after = try std.fs.cwd().readFileAlloc(alloc, lpath, 4096);
    defer alloc.free(after);
    // What the next `mux` reads: one leaf, and a file it does not refuse.
    try std.testing.expect(std.mem.indexOf(u8, after, "--sock /tmp/a.sock#work") != null);
    try std.testing.expect(std.mem.indexOf(u8, after, "box#") == null);
}

test "hosts list: a line the grammar refuses is named, not a refusal of the listing" {
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [512]u8 = undefined;
    const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
    var buf2: [512]u8 = undefined;
    const dead = try std.fmt.bufPrint(&buf2, "{s}/absent.sock", .{tmp.path()});
    var line_buf: [1024]u8 = undefined;
    try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "box#old\n--sock {s}\n", .{dead}));

    var arena_state = std.heap.ArenaAllocator.init(alloc);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    var out_buf: [512]u8 = undefined;
    const out_path = try std.fmt.bufPrint(&out_buf, "{s}/out", .{tmp.path()});
    const out = try std.fs.createFileAbsolute(out_path, .{});
    const rc = try hostsList(arena, path, out.handle);
    out.close();
    // Listing must still display an invalid line so it can be repaired.
    try std.testing.expectEqual(@as(u8, 0), rc);

    const text = try std.fs.cwd().readFileAlloc(alloc, out_path, 4096);
    defer alloc.free(text);
    try std.testing.expect(std.mem.indexOf(u8, text, "box#old\t[bad host: names a session") != null);
    // An invalid row must not prevent subsequent valid hosts from being polled.
    try std.testing.expect(std.mem.indexOf(u8, text, "\t[unreachable]") != null);
}

test "refuseFile: the exit code names whose fault it was, and a bad line in the file does not change it" {
    // Exit code 2 identifies correctable syntax, while exit code 1 identifies
    // an operational failure. Classification must use the original error even
    // when the file also contains an invalid line.
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [512]u8 = undefined;
    const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
    try hosts.saveBytes(path, "box#old\nbox\n");

    var arena_state = std.heap.ArenaAllocator.init(alloc);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    try std.testing.expectEqual(@as(u8, 2), refuseFile(arena, "t", path, error.HasSession));
    try std.testing.expectEqual(@as(u8, 1), refuseFile(arena, "t", path, error.OutOfMemory));
    try std.testing.expectEqual(@as(u8, 1), refuseFile(arena, "t", path, error.AccessDenied));
}

test "hosts list: a line longer than the row buffer is still shown, because rm matches what was shown" {
    // `hosts.loadLines` accepts one-MiB lines and `mux hosts rm` matches exact
    // bytes, so listing must preserve the complete line needed for repair.
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [512]u8 = undefined;
    const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});

    // Use an invalid host to avoid dialing; this isolates formatting of a line
    // longer than the fixed status buffer.
    var long: [900]u8 = undefined;
    @memset(&long, 'h');
    long[899] = '#';
    var line_buf: [1024]u8 = undefined;
    try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "{s}\n", .{long}));

    var arena_state = std.heap.ArenaAllocator.init(alloc);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    var out_buf: [512]u8 = undefined;
    const out_path = try std.fmt.bufPrint(&out_buf, "{s}/out", .{tmp.path()});
    const out = try std.fs.createFileAbsolute(out_path, .{});
    const rc = try hostsList(arena, path, out.handle);
    out.close();
    try std.testing.expectEqual(@as(u8, 0), rc);

    const text = try std.fs.cwd().readFileAlloc(alloc, out_path, 8192);
    defer alloc.free(text);
    // The output must contain the entire line and its status suffix.
    try std.testing.expect(std.mem.startsWith(u8, text, &long));
    try std.testing.expect(std.mem.endsWith(
        u8,
        text,
        "\t[bad host: names a session after '#': a host line names a daemon; the layout names sessions]\n",
    ));
}

test "wall: a LISTED local daemon that nothing answers on is one this wall starts" {
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [256]u8 = undefined;
    const sock = try std.fmt.bufPrintZ(&buf, "{s}/muxd.sock", .{tmp.path()});
    var line_buf: [512]u8 = undefined;
    const line = try std.fmt.bufPrint(&line_buf, "--sock {s}", .{sock});

    var h: hosts.Hosts = .{};
    defer h.deinit(alloc);
    // Include multiple entries with the local socket later in the file to show
    // that its position does not affect startup detection.
    try std.testing.expect(try h.add(alloc, "box"));
    try std.testing.expect(try h.add(alloc, line));

    // The state file contains the socket, but no process is listening yet —
    // and the answer names WHY, since that is what the log line says.
    try std.testing.expectEqual(@as(?anyerror, error.FileNotFound), localNeedsStart(&h, sock));

    const addr = try std.net.Address.initUnix(sock);
    var listener = try addr.listen(.{});
    try std.testing.expectEqual(@as(?anyerror, null), localNeedsStart(&h, sock));
    listener.deinit();
    // The socket file the dead listener left is a different reason.
    try std.testing.expectEqual(@as(?anyerror, error.ConnectionRefused), localNeedsStart(&h, sock));

    // A wall of only remote hosts starts nothing, however dead they are.
    var remote: hosts.Hosts = .{};
    defer remote.deinit(alloc);
    try std.testing.expect(try remote.add(alloc, "box"));
    try std.testing.expectEqual(@as(?anyerror, null), localNeedsStart(&remote, sock));
}

test "hosts: a session count is the daemon's lines, not its bytes" {
    try std.testing.expectEqual(@as(usize, 0), countSessions(""));
    try std.testing.expectEqual(@as(usize, 1), countSessions("0\n"));
    // No trailing newline, and a blank line, are the same two sessions.
    try std.testing.expectEqual(@as(usize, 2), countSessions("0\nwork"));
    try std.testing.expectEqual(@as(usize, 2), countSessions("0\n\nwork\n"));
}

test "hosts: a count shows sessions a wall could paint, not a daemon's lines" {
    // `mux hosts` prints this number beside a host. A daemon that answered
    // with an over-long line or a line holding a space named no session the
    // wall can attach to, so the row must not advertise one.
    try std.testing.expectEqual(@as(usize, 1), countSessions("0\n" ++ ("x" ** 1056)));
    try std.testing.expectEqual(@as(usize, 2), countSessions("0\nhas space\nwork"));
}

test "parseArgs: unknown flags and valueless flags are usage errors" {
    try std.testing.expectError(error.Usage, parse(&.{ "mux", "--wat" }));
    try std.testing.expectError(error.Usage, parse(&.{ "mux", "-x" }));
    // Missing flag values must not be reinterpreted as hostnames. Keep every
    // value-taking client flag in this table so newly added flags require a
    // corresponding test update.
    inline for (.{ "--sock", "--via", "--key", "--quic-idle-ms", "--session" }) |flag| {
        try std.testing.expectError(error.Usage, parse(&.{ "mux", flag }));
    }
}

test "parseArgs: --help is the usage someone asked for, wherever it sits" {
    try std.testing.expectError(error.Help, parse(&.{ "mux", "--help" }));
    try std.testing.expectError(error.Help, parse(&.{ "mux", "-h" }));
    try std.testing.expectError(error.Help, parse(&.{ "mux", "vm1", "--help" }));
    // Help takes precedence even where a value is missing or another argument
    // is invalid.
    try std.testing.expectError(error.Help, parse(&.{ "mux", "--sock", "--help" }));
    try std.testing.expectError(error.Help, parse(&.{ "mux", "--wat", "--help" }));
}

test "-A rides every transport spelling" {
    try std.testing.expect((try parse(&.{ "mux", "-A", "somehost" })).host.agent);
    try std.testing.expect((try parse(&.{ "mux", "-A", "--sock", "/tmp/x.sock" })).attach.agent);
    try std.testing.expect((try parse(&.{ "mux", "quic://h:1", "-A" })).quic.agent);
    try std.testing.expect(!(try parse(&.{ "mux", "somehost" })).host.agent);
    // Verify both the short alias and the generated long spelling.
    try std.testing.expect((try parse(&.{ "mux", "--agent", "--sock", "/tmp/x.sock" })).attach.agent);

    // Bare `mux` bypasses parsing and opens the wall without forwarding. Adding
    // `-A` produces a default-local attach with agent forwarding enabled.
    const armed = try parse(&.{ "mux", "-A" });
    try std.testing.expect(armed.attach.agent);
    try std.testing.expect(armed.attach.sock == null);
    try std.testing.expect(armed.attach.via == null);
}

test "parseArgs: quic:// is a transport like any other" {
    const q = try parse(&.{ "mux", "quic://box:4433", "--key", "/k" });
    try std.testing.expect(q == .quic);
    try std.testing.expectEqualStrings("box:4433", q.quic.host_port);
    try std.testing.expectEqualStrings("/k", q.quic.key.?);
    // Compare with the documented literal rather than the source constant so
    // the test detects an accidental default change.
    try std.testing.expectEqual(@as(u32, 15_000), q.quic.idle_ms);

    // A QUIC target conflicts with every other explicitly named transport,
    // regardless of argument order.
    try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--sock", "/x" }));
    try std.testing.expectError(error.Conflict, parse(&.{ "mux", "--sock", "/x", "quic://a:1", "--key", "/k" }));
    try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--via", "ssh h" }));
    try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "--key", "/k", "vm1" }));
    try std.testing.expectError(error.Conflict, parse(&.{ "mux", "quic://a:1", "quic://b:2", "--key", "/k" }));

    // The scheme with nothing after it names no host.
    try std.testing.expectError(error.Usage, parse(&.{ "mux", "quic://", "--key", "/k" }));
}

test "parseArgs: a quic attach without a key defers to main, which resolves it" {
    // Without an explicit or environment key, parsing defers default-path
    // resolution to `main`.
    const q = try parse(&.{ "mux", "quic://a:1" });
    try std.testing.expect(q == .quic);
    try std.testing.expect(q.quic.key == null);

    // Empty env var means unset, same as an empty --key would be nonsense.
    const empty_env = try parseEnv(&.{ "mux", "quic://a:1" }, "");
    try std.testing.expect(empty_env == .quic);
    try std.testing.expect(empty_env.quic.key == null);

    // The environment supplies it when the flag does not...
    const e = try parseEnv(&.{ "mux", "quic://a:1" }, "/env.key");
    try std.testing.expect(e == .quic);
    try std.testing.expectEqualStrings("/env.key", e.quic.key.?);

    // ...and the flag wins when both are there, because it is the more
    // specific statement of intent.
    const both = try parseEnv(&.{ "mux", "quic://a:1", "--key", "/flag.key" }, "/env.key");
    try std.testing.expectEqualStrings("/flag.key", both.quic.key.?);

    // Ignore `MUX_KEY_FILE` for non-QUIC targets so an exported value cannot
    // break an ordinary local attach.
    try std.testing.expect((try parseEnv(&.{"mux"}, "/env.key")) == .attach);
    try std.testing.expect((try parse(&.{ "mux", "--key", "/k" })) == .attach);
    try std.testing.expect((try parse(&.{ "mux", "--key", "/k", "vm1" })) == .host);
}

test "parseArgs: --quic-idle-ms parses, and refuses what ngtcp2 would invert" {
    const t = try parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "1500" });
    try std.testing.expectEqual(@as(u32, 1500), t.quic.idle_ms);

    // SSH handoff can end in QUIC, so a bare host must retain the configured
    // idle timeout instead of accepting and discarding the flag.
    const h = try parse(&.{ "mux", "vm1", "--quic-idle-ms", "1500" });
    try std.testing.expect(h == .host);
    try std.testing.expectEqual(@as(u32, 1500), h.host.idle_ms);
    try std.testing.expectError(error.Usage, parse(&.{ "mux", "vm1", "--quic-idle-ms", "0" }));

    try std.testing.expectError(error.Usage, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "0" }));
    try std.testing.expectError(error.Usage, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "soon" }));
    try std.testing.expectError(error.Usage, parse(&.{ "mux", "quic://a:1", "--key", "/k", "--quic-idle-ms", "99999999999" }));
    // The bare-flag/missing-value case is covered once, for every
    // value-taking flag, by the valueless-flags sweep above.
}

test "parseArgs: --version wins wherever it appears" {
    try std.testing.expectError(error.Version, parse(&.{ "mux", "--version" }));
    try std.testing.expectError(error.Version, parse(&.{ "mux", "--sock", "/x", "--version" }));
    // Version takes precedence over conflicts and syntax errors elsewhere on
    // the command line.
    try std.testing.expectError(error.Version, parse(&.{ "mux", "vm1", "--sock", "/x", "--version" }));
    try std.testing.expectError(error.Version, parse(&.{ "mux", "--wat", "--version" }));
}

test "parseArgs: --session rides every transport spelling" {
    const s = try parse(&.{ "mux", "--session", "b", "--sock", "/tmp/x.sock" });
    try std.testing.expectEqualStrings("b", s.attach.session);
    const h = try parse(&.{ "mux", "somehost", "--session", "b" });
    try std.testing.expectEqualStrings("b", h.host.session);
    const q = try parse(&.{ "mux", "quic://h:1", "--session", "b" });
    try std.testing.expectEqualStrings("b", q.quic.session);
}

test "parseArgs: a bad --session is a usage error, not a wire experiment" {
    try std.testing.expectError(error.Usage, parse(&.{ "mux", "--session", "has space" }));
}

test "parseArgs: no --session means the empty wire name (older-daemon compat)" {
    const s = try parse(&.{"mux"});
    try std.testing.expectEqualStrings("", s.attach.session);
}

// Ensure every public declaration is semantically analyzed during tests;
// `std.meta.declarations` does not include private declarations.
test {
    std.testing.refAllDeclsRecursive(@This());
}

/// Test server that either returns a valid ssh-agent reply or immediately
/// closes like a mux session with no forwarded agent. A thread is required
/// because the probe performs a write/read round trip.
const AgentStub = struct {
    listener: *std.net.Server,
    answer: bool,
    /// Set only after receiving the exact identities request used by
    /// `ssh-add -l`, proving that the probe sent the expected bytes.
    asked: bool = false,

    fn run(self: *AgentStub) void {
        const conn = self.listener.accept() catch return;
        defer std.posix.close(conn.stream.handle);
        if (!self.answer) return;
        var buf: [64]u8 = undefined;
        const n = std.posix.read(conn.stream.handle, &buf) catch return;
        // Compare with a literal because `agent_request_identities` itself is
        // under test; reusing it here could not detect incorrect bytes.
        self.asked = std.mem.eql(u8, buf[0..n], &[_]u8{ 0, 0, 0, 1, 11 });
        if (!self.asked) return;
        // `SSH_AGENT_IDENTITIES_ANSWER` containing zero keys. The probe checks
        // for a protocol response, not for a nonempty key list.
        const reply = [_]u8{ 0, 0, 0, 5, 12, 0, 0, 0, 0 };
        _ = std.posix.write(conn.stream.handle, &reply) catch {};
    }
};

test "agentReachable: an agent answers; a socket that hangs up is not one" {
    // Cover a live agent, a mux agent socket with no offering client, a stale
    // socket path, and an absent environment value. Only the live agent should
    // allow `-A`.
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [128]u8 = undefined;
    const sock = try std.fmt.bufPrintZ(&buf, "{s}/agent.sock", .{tmp.path()});

    {
        const addr = try std.net.Address.initUnix(sock);
        var listener = try addr.listen(.{});
        defer listener.deinit();
        var stub = AgentStub{ .listener = &listener, .answer = true };
        const th = try std.Thread.spawn(.{}, AgentStub.run, .{&stub});
        const reachable = agentReachable(sock);
        th.join();
        try std.testing.expect(reachable);
        try std.testing.expect(stub.asked);
    }
    std.fs.deleteFileAbsolute(sock) catch {};

    // Inside a session, `SSH_AUTH_SOCK` names the daemon. The connection can
    // succeed even though the daemon closes it after finding no offering
    // client, which is why the probe must exchange a request.
    {
        const addr = try std.net.Address.initUnix(sock);
        var listener = try addr.listen(.{});
        defer listener.deinit();
        var stub = AgentStub{ .listener = &listener, .answer = false };
        const th = try std.Thread.spawn(.{}, AgentStub.run, .{&stub});
        defer th.join();
        try std.testing.expect(!agentReachable(sock));
    }
    std.fs.deleteFileAbsolute(sock) catch {};

    // Treat silence as reachable and immediate EOF as unavailable. A slow or
    // wedged real agent may time out, while the daemon's no-offerer response is
    // an immediate close.
    {
        const addr = try std.net.Address.initUnix(sock);
        var listener = try addr.listen(.{});
        defer listener.deinit();
        try std.testing.expect(agentReachable(sock));
    }

    // A stale socket file still exists after its agent dies, so the probe must
    // connect rather than merely checking the path.
    try std.fs.accessAbsolute(sock, .{});
    try std.testing.expect(!agentReachable(sock));

    // Also cover an empty path and a path with no socket.
    std.fs.deleteFileAbsolute(sock) catch {};
    try std.testing.expect(!agentReachable(sock));
    try std.testing.expect(!agentReachable(""));
}