a73x

src/server/server_test_agent.zig

Ref:   Size: 43.0 KiB   History

const std = @import("std");
const proto = @import("term").protocol;
const quic = @import("quic");
const quic_server = @import("quic_server.zig");
const h = @import("server_test_harness.zig");
const dial = h.dial;
const srv_mod = @import("server.zig");
const Server = srv_mod.Server;
const max_agent_chans = srv_mod.max_agent_chans;
const attachNamed = h.attachNamed;
const ClientSlot = h.ClientSlot;
const Lead = h.Lead;
const pumpUntil = h.pumpUntil;
const awaitFrame = h.awaitFrame;
const awaitGridText = h.awaitGridText;
const findFrame = h.findFrame;
const quicPump = h.quicPump;
const quicTestServer = h.quicTestServer;
const writeDyingGapShell = h.writeDyingGapShell;

/// `statFile` opens, and open(2) on a unix socket is ENXIO, so the obvious
/// spelling reports a missing socket for one that is right there.
fn isSocketAt(path: [:0]const u8) bool {
    const st = std.posix.fstatatZ(std.posix.AT.FDCWD, path, 0) catch return false;
    return std.posix.S.ISSOCK(st.mode);
}

test "Server: a session shell is born with a live SSH_AUTH_SOCK" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentenv", .{ .shell = "/bin/sh" });
    defer td.deinit();

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    try std.testing.expect(td.srv.ses(0).agentFd() >= 0);
    try std.testing.expect(isSocketAt(path));

    // And the shell was TOLD, which is the half no field can answer: the
    // pair reaches the child through the spawn's environment or not at all.
    // Matched on the basename rather than on `test -S` alone, because the
    // box running this test may itself be inside an ssh session — an
    // inherited SSH_AUTH_SOCK would pass a bare "is it a socket" with the
    // daemon's own value never set.
    const cmd = try std.fmt.allocPrint(
        alloc,
        "case \"$SSH_AUTH_SOCK\" in */{s}) test -S \"$SSH_AUTH_SOCK\" && echo AGENT\"\"OK ;; esac\n",
        .{std.fs.path.basename(path)},
    );
    defer alloc.free(cmd);
    try proto.writeAllFd(td.srv.ses(0).pty.master, cmd);
    // The typed line the pty echoes back carries AGENT""OK, so only the
    // command's own output can satisfy this needle.
    try std.testing.expect(try awaitGridText(alloc, &td.srv, "AGENTOK", 5000));
}

test "Server: a reaped session takes its agent socket with it" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.open(alloc, "agentreap");
    defer td.deinit();

    const script = try writeDyingGapShell(alloc, &td.tmp);
    defer alloc.free(script);

    try td.start(.{ .shell = script });

    // Copied, because the reap frees the session's own: the whole point is
    // to ask about the path after the session that owned it is gone.
    const path = try alloc.dupeZ(u8, td.srv.ses(0).agentPath() orelse return error.NoAgentSocket);
    defer alloc.free(path);
    try std.testing.expect(isSocketAt(path));

    // The script reads twice before it exits; the first line is what makes
    // the shell live and pumping before the second one kills it.
    try proto.writeAllFd(td.srv.ses(0).pty.master, "go\n");
    try std.testing.expect(try awaitGridText(alloc, &td.srv, "after-osc", 3000));
    try proto.writeAllFd(td.srv.ses(0).pty.master, "die\n");
    var reaped = false;
    for (0..600) |_| {
        td.srv.pumpOnce(5) catch break;
        if (td.srv.sessions.table[0] == null) {
            reaped = true;
            break;
        }
    }
    try std.testing.expect(reaped);
    // Unlinked with the close, not merely closed: a name left behind is
    // what stops the next session of the same name from binding, and
    // deinit's deleteTree is too late for a daemon that runs on.
    try std.testing.expect(!isSocketAt(path));
}

test "Server: agent_offer flags the slot, and an unknown type leaves the client answering" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "offer", .{ .shell = "/bin/cat" });
    defer td.deinit();

    const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "");
    defer c.close();
    const slot: ClientSlot = .{ .srv = &td.srv, .n = 0 };
    try std.testing.expect(try pumpUntil(&td.srv, 2000, slot, ClientSlot.seated));
    // The offer is opt-in, so the flag must start false or every client
    // would look like it had offered.
    try std.testing.expect(!slot.offering());

    try proto.writeFrame(c.handle, .agent_offer, "");
    try std.testing.expect(try pumpUntil(&td.srv, 2000, slot, ClientSlot.offering));

    // 0x7e is unmapped in MsgType. Skipping it rather than dropping the
    // client is what lets a client offering agent forwarding talk to a
    // daemon built before agent_offer existed, so the liveness assertion is
    // the point: the client is still seated AND still answering.
    try proto.writeFrame(c.handle, @enumFromInt(0x7e), "");
    try proto.writeFrame(c.handle, .stats_req, "");
    const reply = try awaitFrame(alloc, &td.srv, c.handle, .stats_reply, 400);
    defer if (reply) |f| f.deinit(alloc);
    try std.testing.expect(reply != null);
    try std.testing.expect(td.srv.clients[0] != null);
}

// ---------------------------------------------------------------------------
// The blind pump: a connection to a session's SSH_AUTH_SOCK, routed to a
// client or refused, and bytes carried both ways without the daemon reading
// them. Every assertion below is made through the wire — an agent connection
// on one side, frames on the other — because that pair IS the feature; the
// channel table is only how the daemon remembers it.
// ---------------------------------------------------------------------------

/// Test helper: pump until `fd` has something to say, then take it once.
/// Null means it never spoke inside the budget, which the absence probes
/// below read as "and never would have"; 0 is EOF, an answer in its own
/// right. The budget is milliseconds of pumping, not a round count — the
/// counts it replaced were 5 ms rounds wearing no unit at all.
fn pumpUntilReadable(srv: *Server, fd: std.posix.fd_t, buf: []u8, budget_ms: u64) !?usize {
    const Readable = struct {
        fd: std.posix.fd_t,
        fn yes(self: @This()) bool {
            var pfd = [_]std.posix.pollfd{
                .{ .fd = self.fd, .events = std.posix.POLL.IN, .revents = 0 },
            };
            return (std.posix.poll(&pfd, 0) catch 0) != 0;
        }
    };
    if (!try pumpUntil(srv, budget_ms, Readable{ .fd = fd }, Readable.yes)) return null;
    return std.posix.read(fd, buf) catch 0;
}

/// Test helper: seat a client in `slot` and have it volunteer an agent,
/// pumping until the daemon holds both facts. Attaches one at a time by
/// contract — freeClientSlot hands out the lowest free slot, so the caller's
/// slot number is only the attach order if nobody attaches concurrently.
fn attachOffering(srv: *Server, fd: std.posix.fd_t, slot: usize, name: []const u8) !void {
    try attachNamed(fd, 80, 24, name);
    const s: ClientSlot = .{ .srv = srv, .n = slot };
    if (!try pumpUntil(srv, 2000, s, ClientSlot.seated)) return error.ClientNeverSeated;
    try proto.writeFrame(fd, .agent_offer, "");
    if (!try pumpUntil(srv, 2000, s, ClientSlot.offering)) return error.OfferNeverLanded;
}

test "Server: a session with no agent socket does not inherit the daemon's" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.open(alloc, "agentless");
    defer td.deinit();

    // The daemon is given an agent of its own, which is the whole hazard:
    // the daemon is often started from a desktop session that has one, and every
    // session it spawns inherits whatever the daemon's own environment holds.
    const inherited = try std.fmt.allocPrintSentinel(alloc, "{s}/daemons-own.sock", .{td.tmp.path()}, 0);
    defer alloc.free(inherited);
    const libc = @cImport({
        @cInclude("stdlib.h");
    });
    const prior = std.posix.getenv(proto.agent_sock_env);
    const prior_z = if (prior) |p| try alloc.dupeZ(u8, p) else null;
    defer if (prior_z) |p| alloc.free(p);
    _ = libc.setenv(proto.agent_sock_env, inherited.ptr, 1);
    defer if (prior_z) |p| {
        _ = libc.setenv(proto.agent_sock_env, p.ptr, 1);
    } else {
        _ = libc.unsetenv(proto.agent_sock_env);
    };

    try td.start(.{ .shell = "/bin/sh" });

    // Every later failure of makeAgentDir looks exactly like this: no
    // directory, so no session bound after it gets a socket. Reached by
    // nulling the field the failure nulls rather than by a stand-in — and
    // freeing what deinit can no longer see, which the real failure never
    // allocated in the first place.
    if (td.srv.agents.dir) |d| alloc.free(d);
    td.srv.agents.dir = null;

    const probe = try std.fmt.allocPrint(alloc, "{s}/probe", .{td.tmp.path()});
    defer alloc.free(probe);
    const done = try std.fmt.allocPrint(alloc, "{s}/probe.done", .{td.tmp.path()});
    defer alloc.free(done);
    const cmd = try std.fmt.allocPrint(
        alloc,
        "printf %s \"${{{s}-UNSET}}\" > {s}; printf y > {s}\n",
        .{ proto.agent_sock_env, probe, done },
    );
    defer alloc.free(cmd);

    const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "nosock");
    defer c.close();
    if (!try pumpUntil(&td.srv, 2000, ClientSlot{ .srv = &td.srv, .n = 0 }, ClientSlot.seated))
        return error.ClientNeverSeated;
    const si = td.srv.clients[0].?.session orelse return error.ClientNeverSeated;
    try std.testing.expect(td.srv.sessions.table[si].?.agentPath() == null);

    try proto.writeFrame(c.handle, .input, cmd);
    const Wrote = struct {
        path: []const u8,
        fn yes(self: @This()) bool {
            std.fs.accessAbsolute(self.path, .{}) catch return false;
            return true;
        }
    };
    if (!try pumpUntil(&td.srv, 3000, Wrote{ .path = done }, Wrote.yes))
        return error.ShellNeverAnswered;
    const got = std.fs.cwd().readFileAlloc(alloc, probe, 4096) catch
        return error.ShellNeverAnswered;
    defer alloc.free(got);
    // Not the daemon's: a shell pointed at the daemon's own ssh-agent reaches past
    // every client watching it, which is the one thing the socket exists to
    // stop. "No socket" has to mean no agent, not somebody else's.
    try std.testing.expectEqualSlices(u8, "UNSET", got);
}

test "Server: a full channel table refuses the newest dial and says so once" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentfull", .{ .shell = "/bin/cat" });
    defer td.deinit();

    const c = try dial.dial(td.sock_path);
    defer c.close();
    try attachOffering(&td.srv, c.handle, 0, "");
    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;

    // Exactly the table, then one more. ssh opens a channel per auth attempt,
    // so a long-lived agent connection holding a slot is the field failure
    // this counts — eight of them turn forwarding off for every session.
    var dials: [max_agent_chans + 1]std.net.Stream = undefined;
    // Pump between dials, because the listen backlog is `max_agent_chans` and
    // this dials one more than that. A queue that nobody accepts from is full
    // at the last dial, and the two kernels answer that differently: Linux
    // stretches, Darwin refuses the connect outright with ECONNREFUSED, which
    // failed the test before the daemon had a chance to say anything. Letting
    // the daemon accept between dials is also what the field looks like — ssh
    // opens its channels one at a time against a running daemon.
    for (&dials) |*d| {
        d.* = try dial.dial(path);
        try td.srv.pumpOnce(5);
    }
    defer for (dials) |d| d.close();

    const Refusals = struct {
        srv: *Server,
        want: u32,
        fn reached(self: @This()) bool {
            return self.srv.agents.refused_full >= self.want;
        }
    };
    try std.testing.expect(try pumpUntil(&td.srv, 2000, Refusals{ .srv = &td.srv, .want = 1 }, Refusals.reached));
    for (td.srv.agents.chans) |slot| try std.testing.expect(slot != null);
    try std.testing.expectEqual(@as(u32, 1), td.srv.agents.refused_full);
    try std.testing.expectEqual(@as(u32, 0), td.srv.agents.refused_no_offer);

    // Refused means closed, not accepted and left silent: ssh reads the
    // hangup as "agent refused" and moves on, where silence costs it a
    // timeout on every dial.
    var buf: [8]u8 = undefined;
    try std.testing.expectEqual(@as(?usize, 0), try pumpUntilReadable(&td.srv, dials[max_agent_chans].handle, &buf, 300));

    // Ids are unique across the live table, which is the only property both
    // ends rely on — and the counter's wrap must not break it.
    var seen: [max_agent_chans]u32 = undefined;
    for (td.srv.agents.chans, 0..) |slot, i| seen[i] = slot.?.id;
    for (seen, 0..) |id, i| for (seen[i + 1 ..]) |other| try std.testing.expect(id != other);
    td.srv.agents.next_id = seen[3];
    try std.testing.expect(td.srv.agents.nextId() != seen[3]);

    // "Once", which is the half the name claims and nothing asserted: a
    // second dial against the same full table counts again and says nothing,
    // and only a freed slot re-arms the line. ssh retries, so a log that
    // scrolled would be its own outage.
    try std.testing.expect(td.srv.agents.full_said);
    const again = try dial.dial(path);
    defer again.close();
    try std.testing.expect(try pumpUntil(&td.srv, 2000, Refusals{ .srv = &td.srv, .want = 2 }, Refusals.reached));
    try std.testing.expectEqual(@as(u32, 2), td.srv.agents.refused_full);
    try std.testing.expect(td.srv.agents.full_said);
    td.srv.agents.closeChan(&td.srv, 0, .notify);
    try std.testing.expect(!td.srv.agents.full_said);
}

test "Server: an agent connection with nobody offering is refused fast" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentrefuse", .{ .shell = "/bin/cat" });
    defer td.deinit();

    const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "");
    defer c.close();
    const slot0: ClientSlot = .{ .srv = &td.srv, .n = 0 };
    try std.testing.expect(try pumpUntil(&td.srv, 2000, slot0, ClientSlot.seated));
    // Attached but never offered — the case this test is about. The socket
    // exists for as long as the session does; only the answer comes and goes.
    try std.testing.expect(!slot0.offering());

    // An offerer, but watching a DIFFERENT session. A socket per session
    // exists so a dial can be attributed to one shell; an answerer taken
    // from the daemon at large would hand this session's ssh the key of
    // somebody sitting in front of another one.
    const other = try dial.dial(td.sock_path);
    defer other.close();
    try attachOffering(&td.srv, other.handle, 1, "b");
    try std.testing.expect(td.srv.clients[1].?.session != td.srv.clients[0].?.session);

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();

    // EOF, and quickly: ssh reads a closed agent socket as "agent refused
    // operation" and falls straight through to its other auth methods,
    // where a connection left open and silent would make it wait out a
    // timeout on every dial instead.
    var buf: [16]u8 = undefined;
    const n = try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000);
    try std.testing.expectEqual(@as(?usize, 0), n);
}

test "Server: agent bytes pump both ways through a channel" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentpump", .{ .shell = "/bin/cat" });
    defer td.deinit();

    const c = try dial.dial(td.sock_path);
    defer c.close();
    try attachOffering(&td.srv, c.handle, 0, "");

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();

    // The daemon allocates the id and announces the channel; the client
    // never asks for one, because only the daemon knows a connection
    // happened.
    const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
        return error.NoAgentOpen;
    defer open.deinit(alloc);
    const id = try proto.decodeAgentId(open.payload);

    // Client to agent. "req" is not an agent request and deliberately so:
    // the daemon must carry bytes it cannot parse, or it has opinions about
    // a protocol it has no stake in.
    var req: [proto.agent_id_len + 3]u8 = undefined;
    @memcpy(req[0..proto.agent_id_len], &proto.encodeAgentId(id));
    @memcpy(req[proto.agent_id_len..], "req");
    try proto.writeFrame(c.handle, .agent_data, &req);
    var buf: [64]u8 = undefined;
    const n = (try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000)) orelse
        return error.NoAgentRequest;
    try std.testing.expectEqualSlices(u8, "req", buf[0..n]);

    // Agent to client, on the same id.
    try proto.writeAllFd(agent.handle, "resp");
    const data = (try awaitFrame(alloc, &td.srv, c.handle, .agent_data, 200)) orelse
        return error.NoAgentReply;
    defer data.deinit(alloc);
    try std.testing.expectEqual(id, try proto.decodeAgentId(data.payload));
    try std.testing.expectEqualSlices(u8, "resp", data.payload[proto.agent_id_len..]);

    // Half-closed rather than closed, so the test's own `defer` is still the
    // only close of this descriptor. The daemon sees the same read of 0 it
    // would from an ssh that finished and exited.
    try std.posix.shutdown(agent.handle, .send);
    const closed = (try awaitFrame(alloc, &td.srv, c.handle, .agent_close, 200)) orelse
        return error.NoAgentClose;
    defer closed.deinit(alloc);
    try std.testing.expectEqual(id, try proto.decodeAgentId(closed.payload));
}

test "Server: an agent connection is routed to the latest-active offerer" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentroute", .{ .shell = "/bin/cat" });
    defer td.deinit();

    const ca = try dial.dial(td.sock_path);
    defer ca.close();
    try attachOffering(&td.srv, ca.handle, 0, "");
    const cb = try dial.dial(td.sock_path);
    defer cb.close();
    try attachOffering(&td.srv, cb.handle, 1, "");

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;

    // Two dials, with the lead changing hands in between, because ONE dial
    // cannot tell the rule apart from the wrong ones: whoever offered first,
    // the lowest slot, the highest slot, and "decided once for the daemon"
    // each answer a single connection identically to latest-wins. Only the
    // pair of answers disagrees.
    try proto.writeFrame(cb.handle, .input, "x");
    try std.testing.expect(try pumpUntil(&td.srv, 2000, Lead{ .srv = &td.srv, .ahead = 1, .behind = 0 }, Lead.taken));

    const first = try dial.dial(path);
    defer first.close();
    const to_b = (try awaitFrame(alloc, &td.srv, cb.handle, .agent_open, 200)) orelse
        return error.NoAgentOpenForB;
    defer to_b.deinit(alloc);
    // And A is not told, which is the half that matters for keys: an
    // announcement to every offerer would be every offerer's agent asked to
    // sign. With the positive already asserted, a bounded null here means
    // the frame was routed, not merely slow.
    const stray_a = try awaitFrame(alloc, &td.srv, ca.handle, .agent_open, 60);
    defer if (stray_a) |f| f.deinit(alloc);
    try std.testing.expect(stray_a == null);

    // A takes the lead back, and the NEXT dial follows it. The channel
    // already open stays B's regardless — it is mid-exchange with an ssh
    // that would fail the signature rather than change identity.
    try proto.writeFrame(ca.handle, .input, "y");
    try std.testing.expect(try pumpUntil(&td.srv, 2000, Lead{ .srv = &td.srv, .ahead = 0, .behind = 1 }, Lead.taken));

    const second = try dial.dial(path);
    defer second.close();
    const to_a = (try awaitFrame(alloc, &td.srv, ca.handle, .agent_open, 200)) orelse
        return error.NoAgentOpenForA;
    defer to_a.deinit(alloc);
    try std.testing.expect(
        (try proto.decodeAgentId(to_a.payload)) != (try proto.decodeAgentId(to_b.payload)),
    );
    const stray_b = try awaitFrame(alloc, &td.srv, cb.handle, .agent_open, 60);
    defer if (stray_b) |f| f.deinit(alloc);
    try std.testing.expect(stray_b == null);
}

test "Server: agent_data for an unknown or another client's channel is dropped" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentforeign", .{ .shell = "/bin/cat" });
    defer td.deinit();

    const ca = try dial.dial(td.sock_path);
    defer ca.close();
    try attachOffering(&td.srv, ca.handle, 0, "");
    const cb = try dial.dial(td.sock_path);
    defer cb.close();
    try attachOffering(&td.srv, cb.handle, 1, "");

    // B attached last, so the channel is B's.
    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();
    const open = (try awaitFrame(alloc, &td.srv, cb.handle, .agent_open, 200)) orelse
        return error.NoAgentOpen;
    defer open.deinit(alloc);
    const id = try proto.decodeAgentId(open.payload);

    // A names B's id, and then an id nobody holds. Neither may reach the
    // socket: ids are daemon-wide, so a client that guessed one would
    // otherwise be talking into a stranger's ssh-agent.
    var foreign: [proto.agent_id_len + 8]u8 = undefined;
    @memcpy(foreign[0..proto.agent_id_len], &proto.encodeAgentId(id));
    @memcpy(foreign[proto.agent_id_len..], "trespass");
    try proto.writeFrame(ca.handle, .agent_data, &foreign);
    var unknown: [proto.agent_id_len + 1]u8 = undefined;
    @memcpy(unknown[0..proto.agent_id_len], &proto.encodeAgentId(id +% 1000));
    unknown[proto.agent_id_len] = 'x';
    try proto.writeFrame(cb.handle, .agent_data, &unknown);
    var buf: [64]u8 = undefined;
    try std.testing.expect((try pumpUntilReadable(&td.srv, agent.handle, &buf, 300)) == null);
    // Dropped, not filed: an id nobody holds must not be an id anybody can
    // conjure a channel with.
    try std.testing.expect(td.srv.agents.chans[1] == null);

    // The positive control, so the silence above is a refusal and not a
    // pump that was never working: the owner's bytes still land.
    var mine: [proto.agent_id_len + 2]u8 = undefined;
    @memcpy(mine[0..proto.agent_id_len], &proto.encodeAgentId(id));
    @memcpy(mine[proto.agent_id_len..], "ok");
    try proto.writeFrame(cb.handle, .agent_data, &mine);
    const n = (try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000)) orelse
        return error.NoAgentRequest;
    try std.testing.expectEqualSlices(u8, "ok", buf[0..n]);

    // And neither sender was punished for asking: an id that names nothing
    // is dropped like an unknown frame type, not answered with a hangup.
    try std.testing.expect(td.srv.clients[0] != null);
    try std.testing.expect(td.srv.clients[1] != null);
    try proto.writeFrame(ca.handle, .stats_req, "");
    const reply = try awaitFrame(alloc, &td.srv, ca.handle, .stats_reply, 400);
    defer if (reply) |f| f.deinit(alloc);
    try std.testing.expect(reply != null);
}

test "Server: an agent_data frame past the cap hangs the channel up" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentbig", .{ .shell = "/bin/cat" });
    defer td.deinit();

    const c = try dial.dial(td.sock_path);
    defer c.close();
    try attachOffering(&td.srv, c.handle, 0, "");

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();
    const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
        return error.NoAgentOpen;
    defer open.deinit(alloc);
    const id = try proto.decodeAgentId(open.payload);

    // One byte past what any shipped sender produces. `agent_data_max` is a
    // send-side buffer size, so the only thing making it a rule on this side
    // is this refusal — and the write it guards is blocking.
    const over = try alloc.alloc(u8, proto.agent_id_len + proto.agent_data_max + 1);
    defer alloc.free(over);
    @memset(over, 'x');
    @memcpy(over[0..proto.agent_id_len], &proto.encodeAgentId(id));
    try proto.writeFrame(c.handle, .agent_data, over);

    // The channel goes, and the client hears so: it is holding an fd to the
    // local agent that nothing will answer on again.
    const closed = (try awaitFrame(alloc, &td.srv, c.handle, .agent_close, 200)) orelse
        return error.NoAgentClose;
    defer closed.deinit(alloc);
    try std.testing.expectEqual(id, try proto.decodeAgentId(closed.payload));
    try std.testing.expect(td.srv.agents.find(id, 0) == null);

    // Not one byte of it reached the agent: a truncated request is worse
    // than none, and EOF here is the hangup rather than an idle socket.
    var buf: [64]u8 = undefined;
    try std.testing.expectEqual(@as(?usize, 0), try pumpUntilReadable(&td.srv, agent.handle, &buf, 300));

    // And the client itself survives — the frame was refused, not the peer.
    try proto.writeFrame(c.handle, .stats_req, "");
    const reply = try awaitFrame(alloc, &td.srv, c.handle, .stats_reply, 400);
    defer if (reply) |f| f.deinit(alloc);
    try std.testing.expect(reply != null);
}

test "Server: a client closing a channel hangs up the agent connection without an echo" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentcliclose", .{ .shell = "/bin/cat" });
    defer td.deinit();

    const c = try dial.dial(td.sock_path);
    defer c.close();
    try attachOffering(&td.srv, c.handle, 0, "");

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();
    const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
        return error.NoAgentOpen;
    defer open.deinit(alloc);
    const id = try proto.decodeAgentId(open.payload);

    // The other direction of close: the client's ssh-agent hung up, or the
    // client is going away tidily. The connection must end here too, or a
    // channel the client has forgotten holds a descriptor forever.
    try proto.writeFrame(c.handle, .agent_close, &proto.encodeAgentId(id));
    var buf: [16]u8 = undefined;
    try std.testing.expectEqual(
        @as(?usize, 0),
        try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000),
    );
    try std.testing.expect(td.srv.agents.chans[0] == null);

    // And no agent_close comes back: the client asked for this, so an echo
    // is an event it would have to learn to ignore — and one it could not
    // tell from the far end closing a channel it had reopened.
    const echo = try awaitFrame(alloc, &td.srv, c.handle, .agent_close, 60);
    defer if (echo) |f| f.deinit(alloc);
    try std.testing.expect(echo == null);
}

test "Server: the daemon listener, the clients it accepts, an agent listener and its channels are close-on-exec" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentcloexec", .{ .shell = "/bin/cat" });
    defer td.deinit();

    const c = try dial.dial(td.sock_path);
    defer c.close();
    try attachOffering(&td.srv, c.handle, 0, "");

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();
    const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
        return error.NoAgentOpen;
    defer open.deinit(alloc);

    // Asked of the descriptors themselves rather than read off the call
    // that made them: this daemon forks a shell per session, and every
    // session created after this connection would inherit anything missing
    // the flag — an agent socket a shell could talk to directly, and a live
    // channel that no close of ours could ever finish closing.
    const flags = std.posix.FD_CLOEXEC;
    try std.testing.expect(
        try std.posix.fcntl(td.srv.ses(0).agentFd(), std.posix.F.GETFD, 0) & flags != 0,
    );
    const ch = td.srv.agents.chans[0] orelse return error.NoChannel;
    try std.testing.expect(try std.posix.fcntl(ch.fd, std.posix.F.GETFD, 0) & flags != 0);

    // The daemon's own listener and the client connection it accepted, on
    // the same grounds and unpinned until now: `serve.bind` and the raw
    // `posix.accept` that replaced `std.net` both have to be ASKED for this
    // flag, where std.net set it unconditionally, so a conversion can drop
    // it and stay green everywhere else.
    try std.testing.expect(try std.posix.fcntl(td.srv.bound.fd, std.posix.F.GETFD, 0) & flags != 0);
    const seated = td.srv.clients[0] orelse return error.ClientNeverSeated;
    const seated_fd = switch (seated.sink) {
        .socket => |fd| fd,
        .quic => return error.NotAUnixClient,
    };
    try std.testing.expect(try std.posix.fcntl(seated_fd, std.posix.F.GETFD, 0) & flags != 0);
}

test "Server: a client's agent channels die with the client" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentorphan", .{ .shell = "/bin/cat" });
    defer td.deinit();

    const c = try dial.dial(td.sock_path);
    try attachOffering(&td.srv, c.handle, 0, "");

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();
    const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
        return error.NoAgentOpen;
    defer open.deinit(alloc);
    try std.testing.expect(td.srv.agents.chans[0] != null);

    // The client vanishes mid-exchange — a lid closed, a WAN link dropped.
    // The ssh at the far end is waiting on a signature that can no longer
    // be produced, so the honest answer is the one a missing agent gives:
    // EOF now, not a channel held open against a client that is gone.
    c.close();
    var buf: [16]u8 = undefined;
    const n = try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000);
    try std.testing.expectEqual(@as(?usize, 0), n);
    try std.testing.expect(td.srv.clients[0] == null);
    try std.testing.expect(td.srv.agents.chans[0] == null);
}

test "Server: a QUIC client's agent channels die with the client" {
    const alloc = std.testing.allocator;
    var td = try h.TestDaemon.init(alloc, "qagentgone", .{ .shell = "/bin/sh" });
    const key: quic.Key = .{ .bytes = [_]u8{0x7E} ** quic.key_len };
    const q = try quicTestServer(&td.srv, key);
    // The listener outlives the server: a QUIC sink closes its connection
    // THROUGH the listener, so `Server.deinit` walking its client table needs
    // it alive. Defers run in reverse, so the daemon's goes second. The
    // success path at the end clears this client by hand and so would not
    // notice the other order; every early return before it would, with a
    // live slot still in the table.
    defer q.l.deinit();
    defer td.deinit();

    var cl = try quic_server.TestPeer.init(q.addr, key);
    defer cl.deinit();

    // attachOver cannot say "offering", and the offer rides its own frame
    // re-sent after every attach, so both go out in one flight here.
    var cbuf: std.ArrayList(u8) = .empty;
    defer cbuf.deinit(alloc);
    try proto.appendFrame(&cbuf, alloc, .attach, &proto.encodeAttach(80, 24, 0, 0));
    try proto.appendFrame(&cbuf, alloc, .agent_offer, "");
    cl.out = cbuf.items;
    cl.drain();

    var only = [_]*quic_server.TestPeer{&cl};
    try quicPump(&td.srv, &only, 10000, &td.srv, struct {
        fn f(s: *Server) bool {
            return s.clients[0] != null and s.clients[0].?.agent_offer;
        }
    }.f);
    if (td.srv.clients[0] == null or !td.srv.clients[0].?.agent_offer) return error.ClientNeverOffered;

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();
    try quicPump(&td.srv, &only, 10000, &cl, struct {
        fn f(t: *quic_server.TestPeer) bool {
            return findFrame(t.cl.in.items, .agent_open) != null;
        }
    }.f);
    if (td.srv.agents.chans[0] == null) return error.NoAgentOpen;

    // quic_server.Listener.kill is private; closeConn plus a hand-called
    // quicOnClose is faithful to it because it repeats kill's order — the
    // conn is freed, then the callback runs. The socket twin above carries
    // the scenario.
    const qid = td.srv.clients[0].?.sink.quic.id;
    q.l.closeConn(qid);
    Server.quicOnClose(@ptrCast(&td.srv), qid);
    try std.testing.expect(td.srv.clients[0] == null);
    // Same verdict the socket path hands down in dropClient: the ssh at
    // the far end waits on a signature nobody can produce any more, and a
    // channel left behind would route into whoever next takes the slot.
    var abuf: [16]u8 = undefined;
    try std.testing.expectEqual(
        @as(?usize, 0),
        try pumpUntilReadable(&td.srv, agent.handle, &abuf, 1000),
    );
    try std.testing.expect(td.srv.agents.chans[0] == null);
}

/// Test helper: a channel brought to the moment the answer clock starts.
fn openAndAsk(
    alloc: std.mem.Allocator,
    srv: *Server,
    client_fd: std.posix.fd_t,
    agent_fd: std.posix.fd_t,
) !u32 {
    const open = (try awaitFrame(alloc, srv, client_fd, .agent_open, 200)) orelse
        return error.NoAgentOpen;
    defer open.deinit(alloc);
    const id = try proto.decodeAgentId(open.payload);
    try proto.writeAllFd(agent_fd, "req");
    const data = (try awaitFrame(alloc, srv, client_fd, .agent_data, 200)) orelse
        return error.RequestNeverForwarded;
    data.deinit(alloc);
    return id;
}

test "Server: an offerer that never answers its first request is hung up on and stops offering" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentmute", .{ .shell = "/bin/cat" });
    defer td.deinit();
    td.srv.agents.answer_ms = 150;

    const c = try dial.dial(td.sock_path);
    defer c.close();
    try attachOffering(&td.srv, c.handle, 0, "");

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();
    const id = try openAndAsk(alloc, &td.srv, c.handle, agent.handle);

    // The client says nothing. Measured (decisions.md): ssh on a socket
    // that accepts and never replies blocks past 8s; on one that accepts
    // and closes it falls through to its next auth method in 2ms. So the
    // daemon's answer to silence is the close the client should have sent.
    var buf: [16]u8 = undefined;
    const n = try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000);
    try std.testing.expectEqual(@as(?usize, 0), n);
    try std.testing.expect(td.srv.agents.chans[0] == null);
    // The client is told, like any other far-end close it did not ask for.
    const closed = (try awaitFrame(alloc, &td.srv, c.handle, .agent_close, 200)) orelse
        return error.NoAgentClose;
    defer closed.deinit(alloc);
    try std.testing.expectEqual(id, try proto.decodeAgentId(closed.payload));
    // And it is no longer an offerer: the next dial must not route here
    // again, or every ssh pays the bound before falling through.
    try std.testing.expect(!td.srv.clients[0].?.agent_offer);
    const again = try dial.dial(path);
    defer again.close();
    try std.testing.expectEqual(@as(?usize, 0), try pumpUntilReadable(&td.srv, again.handle, &buf, 300));
    try std.testing.expectEqual(@as(u32, 1), td.srv.agents.refused_no_offer);
}

test "Server: a channel that has answered once is never timed out" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentslow", .{ .shell = "/bin/cat" });
    defer td.deinit();
    td.srv.agents.answer_ms = 150;

    const c = try dial.dial(td.sock_path);
    defer c.close();
    try attachOffering(&td.srv, c.handle, 0, "");

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();
    const id = try openAndAsk(alloc, &td.srv, c.handle, agent.handle);

    // One reply proves the client speaks for an agent.
    var resp: [proto.agent_id_len + 4]u8 = undefined;
    @memcpy(resp[0..proto.agent_id_len], &proto.encodeAgentId(id));
    @memcpy(resp[proto.agent_id_len..], "resp");
    try proto.writeFrame(c.handle, .agent_data, &resp);
    var buf: [64]u8 = undefined;
    _ = (try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000)) orelse return error.NoReply;

    // A second request the client takes its time over — a SIGN against a
    // token waiting for a touch. The bound is for a peer that cannot
    // answer, and must not separate slow from refused (the preflight's
    // rule, decisions.md): this is the arm that would pass silently if the
    // clock were anchored on the open or re-armed per request.
    try proto.writeAllFd(agent.handle, "req2");
    const fwd = (try awaitFrame(alloc, &td.srv, c.handle, .agent_data, 200)) orelse
        return error.RequestNeverForwarded;
    fwd.deinit(alloc);
    try std.testing.expectEqual(@as(?usize, null), try pumpUntilReadable(&td.srv, agent.handle, &buf, 500));
    try std.testing.expect(td.srv.agents.chans[0] != null);
    try std.testing.expect(td.srv.clients[0].?.agent_offer);
}

test "Server: bytes a client sends before it was asked prove nothing" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agenteager", .{ .shell = "/bin/cat" });
    defer td.deinit();
    td.srv.agents.answer_ms = 150;

    const c = try dial.dial(td.sock_path);
    defer c.close();
    try attachOffering(&td.srv, c.handle, 0, "");

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();
    const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
        return error.NoAgentOpen;
    defer open.deinit(alloc);
    const id = try proto.decodeAgentId(open.payload);

    // Unsolicited bytes on the fresh channel. A reply is the proof; a
    // volley before any question is not, or a mute peer clears the clock
    // by talking first and then wedging the real request.
    var eager: [proto.agent_id_len + 2]u8 = undefined;
    @memcpy(eager[0..proto.agent_id_len], &proto.encodeAgentId(id));
    @memcpy(eager[proto.agent_id_len..], "hi");
    try proto.writeFrame(c.handle, .agent_data, &eager);
    var buf: [64]u8 = undefined;
    _ = (try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000)) orelse return error.NotForwarded;

    // Then ssh asks and the client goes quiet: the clock must still run.
    try proto.writeAllFd(agent.handle, "req");
    const fwd = (try awaitFrame(alloc, &td.srv, c.handle, .agent_data, 200)) orelse
        return error.RequestNeverForwarded;
    fwd.deinit(alloc);
    try std.testing.expectEqual(@as(?usize, 0), try pumpUntilReadable(&td.srv, agent.handle, &buf, 1000));
    try std.testing.expect(!td.srv.clients[0].?.agent_offer);
}

test "Server: a channel nobody has asked anything on is not timed out" {
    const alloc = std.testing.allocator;

    var td = try h.TestDaemon.init(alloc, "agentidle", .{ .shell = "/bin/cat" });
    defer td.deinit();
    td.srv.agents.answer_ms = 150;

    const c = try dial.dial(td.sock_path);
    defer c.close();
    try attachOffering(&td.srv, c.handle, 0, "");

    const path = td.srv.ses(0).agentPath() orelse return error.NoAgentSocket;
    const agent = try dial.dial(path);
    defer agent.close();
    const open = (try awaitFrame(alloc, &td.srv, c.handle, .agent_open, 200)) orelse
        return error.NoAgentOpen;
    open.deinit(alloc);

    // ssh dialled and has not asked yet. The client owes nothing until it
    // has been handed a request, so a clock anchored on the open would
    // hang up on a working client for the peer's pause.
    var buf: [16]u8 = undefined;
    try std.testing.expectEqual(@as(?usize, null), try pumpUntilReadable(&td.srv, agent.handle, &buf, 500));
    try std.testing.expect(td.srv.agents.chans[0] != null);
    try std.testing.expect(td.srv.clients[0].?.agent_offer);
}

test "Server: ending one session unlinks ITS agent socket and leaves every other session's alone" {
    const alloc = std.testing.allocator;

    // Two sessions, because one cannot show the failure this pins: a
    // teardown that unlinks by name without the guard, or one that sweeps
    // the agent directory, is indistinguishable from a correct one until a
    // second socket is sitting there to be destroyed.
    var td = try h.TestDaemon.init(alloc, "agentplural", .{ .shell = "/bin/sh" });
    defer td.deinit();

    const keep = try dial.dialAttachNamed(td.sock_path, 80, 24, "keep");
    defer keep.close();
    (try awaitFrame(alloc, &td.srv, keep.handle, .snapshot, 400) orelse
        return error.NoState).deinit(alloc);
    const doomed = try dial.dialAttachNamed(td.sock_path, 80, 24, "doomed");
    defer doomed.close();
    (try awaitFrame(alloc, &td.srv, doomed.handle, .snapshot, 400) orelse
        return error.NoState).deinit(alloc);

    var keep_path: ?[:0]const u8 = null;
    var doomed_path: ?[:0]const u8 = null;
    for (&td.srv.sessions.table) |*slot| {
        const s = &(slot.* orelse continue);
        if (std.mem.eql(u8, s.name(), "keep")) keep_path = s.agentPath();
        if (std.mem.eql(u8, s.name(), "doomed")) doomed_path = s.agentPath();
    }
    const kp = keep_path orelse return error.NoAgentSocket;
    // Copied, not borrowed: the ending session frees its path, and the
    // assertion below has to outlive the free it is checking.
    const dp = try alloc.dupeZ(u8, doomed_path orelse return error.NoAgentSocket);
    defer alloc.free(dp);
    try std.testing.expect(isSocketAt(kp));
    try std.testing.expect(isSocketAt(dp));

    var rq: [proto.end_req_max_len]u8 = undefined;
    try proto.writeFrame(doomed.handle, .end_req, proto.encodeEndReq(&rq, false, "doomed"));
    const r = (try awaitFrame(alloc, &td.srv, doomed.handle, .end_reply, 400)) orelse
        return error.NoEndReply;
    defer r.deinit(alloc);
    try std.testing.expect((proto.parseEndReply(r.payload) orelse
        return error.BadEndReply).accepted);

    const Gone = struct {
        path: [:0]const u8,
        fn yes(self: @This()) bool {
            return !isSocketAt(self.path);
        }
    };
    try std.testing.expect(try pumpUntil(&td.srv, 3000, Gone{ .path = dp }, Gone.yes));

    // Asked of the filesystem, not of the daemon: a table that has forgotten
    // a session says nothing about whether its name left the directory, and
    // the leftover file is what the next session of that name trips over.
    try std.testing.expect(!isSocketAt(dp));
    try std.testing.expect(isSocketAt(kp));
}

test "AgentRelay.makeDir reaps a dead daemon's agent directory beside the socket, not a live one's" {
    const testtmp = @import("testtmp");
    var tmp = try testtmp.TmpDir.make();
    defer tmp.cleanup();
    const dead = try testtmp.deadPid();
    var nb: [48]u8 = undefined;
    const left = try std.fmt.bufPrint(&nb, "mux-agent-{d}-000000000000", .{dead});
    try tmp.dir.makePath(left);
    try tmp.dir.makePath("mux-agent-1-000000000000");
    var sb: [64]u8 = undefined;
    const sock = try std.fmt.bufPrint(&sb, "{s}/muxd.sock", .{tmp.path()});

    const dir = @import("server_agent.zig").AgentRelay.makeDir(std.testing.allocator, sock) orelse
        return error.TestUnexpectedResult;
    defer std.testing.allocator.free(dir);
    try std.testing.expectError(error.FileNotFound, tmp.dir.access(left, .{}));
    try tmp.dir.access("mux-agent-1-000000000000", .{});
}