Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/pod-guest-door-dir-host-agnostic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@bounded-systems/prx": patch
---

Mount the door fabric at a host-agnostic in-container path (`guestDoorDir`,
default `/run/prx/doors`) instead of the real host `doorDir`. Previously
every room mounted the fabric at the identical host path (e.g.
`/Users/bobby/.local/run/prx/doors`), letting a guest fingerprint the host's
OS, username, and home layout from its own mount table / env vars even
though the doors themselves are otherwise access-controlled. `doorDir` now
means the host-side source only; all in-container references (kube
`mountPath`, `Volume=`/`--volume` destinations, `podRoomEnv`'s projected
env, `--socket` args) resolve through `guestDoorDir`.
3 changes: 2 additions & 1 deletion packages/prx/src/room/per-repo-pod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { claudeRoom } from "./claude-room.ts";
import { DOLT_BOX_IMAGE, DOLT_BOX_ENV, DOLT_DATA_VOLUME, DOLT_DATA_DIR } from "./dolt-service.ts";
import { forgeDRoom } from "./forge-d-room.ts";
import { keeperdRoom } from "./keeperd-room.ts";
import { DEFAULT_DOOR_DIR } from "./pod.ts";
import { DEFAULT_DOOR_DIR, DEFAULT_GUEST_DOOR_DIR } from "./pod.ts";
import type { PodSpec } from "./pod.ts";
import { podFor, type SlugResolver } from "./pod-identity.ts";
import type { PodService } from "./spec.ts";
Expand All @@ -49,6 +49,7 @@ export const perRepoPod: PodSpec = {
rooms: [claudeRoom, beadsdRoom, keeperdRoom, forgeDRoom, beadsdBridgeRoom],
services: [doltService],
doorDir: DEFAULT_DOOR_DIR,
guestDoorDir: DEFAULT_GUEST_DOOR_DIR,
};

/**
Expand Down
26 changes: 24 additions & 2 deletions packages/prx/src/room/pod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ export const DEFAULT_DOOR_DIR = getEnv("XDG_RUNTIME_DIR")
? `${getEnv("XDG_RUNTIME_DIR")}/prx/doors`
: `${getEnv("HOME") ?? "/tmp"}/.local/run/prx/doors`;

/**
* The default IN-CONTAINER mount path for the door fabric — deliberately
* host-agnostic (no username, no home-dir layout), unlike {@link DEFAULT_DOOR_DIR}
* which is a real host path. A 2026-07-03 finding: mounting the fabric at the
* SAME path on both sides (`${doorDir}:${doorDir}`) let a guest fingerprint the
* host (macOS, username, home layout) purely from its own mountinfo/env, even
* though the doors themselves are unstatable/credential-free. Guest-side code
* should always resolve door sockets via {@link podRoomEnv}'s projected env
* vars, never by reconstructing a path from `doorDir` — this constant exists
* so a guest ps/mount listing reveals nothing about the host.
*/
export const DEFAULT_GUEST_DOOR_DIR = "/run/prx/doors";

export const PodSpecSchema = z.object({
name: z.string().min(1),
/** The shared house — the executor every member room runs in (per-repo). */
Expand All @@ -48,8 +61,17 @@ export const PodSpecSchema = z.object({
* with their named data volumes; they expose no doors. Empty by default.
*/
services: z.array(PodServiceSchema).default([]),
/** The shared tmpfs dir the door sockets live on. */
/** The shared tmpfs dir the door sockets live on, on the HOST. */
doorDir: z.string().min(1).default(DEFAULT_DOOR_DIR),
/**
* The path the door fabric is mounted at INSIDE each room's container —
* deliberately distinct from {@link doorDir} so the guest can't infer the
* host's OS/username/home layout from its own mount table (2026-07-03
* finding). Every in-container reference (kube `mountPath`, `Volume=`
* destinations, the env `podRoomEnv` projects, `--socket` args) uses this;
* only host-side provisioning/polling uses the real `doorDir`.
*/
guestDoorDir: z.string().min(1).default(DEFAULT_GUEST_DOOR_DIR),
/**
* Absolute host path of the repo this pod operates on, bind-mounted at `/work`
* (the daemon images' `WorkingDir`) in every room (prx-u5lx). One pod = one
Expand Down Expand Up @@ -178,7 +200,7 @@ export function podRoomEnv(pod: PodSpec, roomName: string): Record<string, strin
const { resolved } = resolvePodDoors(p);
return resolved
.filter((d) => d.consumer === roomName)
.reduce<Record<string, string>>((env, d) => ({ ...env, ...doorEnv(d, p.doorDir) }), {});
.reduce<Record<string, string>>((env, d) => ({ ...env, ...doorEnv(d, p.guestDoorDir) }), {});
}

/**
Expand Down
30 changes: 18 additions & 12 deletions packages/prx/src/room/podman.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export function renderPodmanKube(pod: PodSpec): string {
}
lines.push(" volumeMounts:");
lines.push(` - name: ${DOOR_VOLUME}`);
lines.push(` mountPath: ${dq(p.doorDir)}`);
lines.push(` mountPath: ${dq(p.guestDoorDir)}`);
if (p.repo) {
lines.push(` - name: ${REPO_VOLUME}`);
lines.push(` mountPath: ${dq(WORK_DIR)}`);
Expand Down Expand Up @@ -184,7 +184,7 @@ export function renderPodmanKube(pod: PodSpec): string {
// qualifies while claude-room's sealed `control` does not.
const socketArgs = room.doors
.filter((d) => d.direction === "expose" && d.state !== "closed")
.flatMap((d) => ["--socket", `${p.doorDir}/${d.socket.split("/").at(-1) ?? d.socket}`]);
.flatMap((d) => ["--socket", `${p.guestDoorDir}/${d.socket.split("/").at(-1) ?? d.socket}`]);
// room.extraArgs (already honored by the secret/run path, renderPodmanRun)
// is honored here too — e.g. a door-bridge room (prx-8uf2) with no exposed
// door of its own, whose CMD is `sh -c 'prx door bridge --port <p> --socket
Expand Down Expand Up @@ -318,12 +318,15 @@ export function renderPodmanRun(pod: PodSpec, roomName: string): string[] {
}
// The shared door fabric: the SAME host doorDir the kube pod hostPath-mounts,
// so this standalone container's exposed door is reachable across runtimes.
// Mounted at guestDoorDir (host-agnostic), NOT the host doorDir itself — a
// same-path mount would let the container fingerprint the host's OS/user/
// home layout from its own mount table (2026-07-03 finding).
// `:z` = SHARED SELinux relabel (container_file_t, shared) — required on an
// SELinux-enforcing host (e.g. a Fedora podman machine), else the keeper hits
// EACCES creating its socket on the unlabeled (var_run_t) dir; SHARED (`:z`)
// not private (`:Z`) because the dir is shared with the kube pod's containers.
// A no-op on non-SELinux hosts. Live-validated on the host (prx-3urm).
args.push("--volume", `${p.doorDir}:${p.doorDir}:z`);
args.push("--volume", `${p.doorDir}:${p.guestDoorDir}:z`);
// TCP port mapping for rooms that declare tcpPort — the macOS virtiofs
// workaround (prx-zj8): virtiofs exposes the socket file but not the socket
// semantics, so the Mac-host client can't connect via Unix; publishing a port
Expand Down Expand Up @@ -356,7 +359,7 @@ export function renderPodmanRun(pod: PodSpec, roomName: string): string[] {
for (const door of room.doors) {
if (door.direction === "expose") {
const socketFile = door.socket.split("/").at(-1) ?? door.socket;
args.push("--env", `${door.name.toUpperCase()}_SOCK=${p.doorDir}/${socketFile}`);
args.push("--env", `${door.name.toUpperCase()}_SOCK=${p.guestDoorDir}/${socketFile}`);
}
}
// The room's -box image; full registry ref resolved at deploy (prx-zj8).
Expand All @@ -368,7 +371,7 @@ export function renderPodmanRun(pod: PodSpec, roomName: string): string[] {
for (const door of room.doors) {
if (door.direction === "expose") {
const socketFile = door.socket.split("/").at(-1) ?? door.socket;
args.push("--socket", `${p.doorDir}/${socketFile}`);
args.push("--socket", `${p.guestDoorDir}/${socketFile}`);
}
}
// Room-specific CMD arg overrides (e.g. keeperd's --key <target>).
Expand Down Expand Up @@ -445,13 +448,16 @@ export function renderPodmanQuadlet(pod: PodSpec, roomName: string): string {
lines.push(`Secret=${secret.name},target=${secret.target}`);
}
// The shared door fabric — the SAME host doorDir the kube pod hostPath-mounts,
// so this unit's exposed door is reachable across runtimes. `:z` (shared
// SELinux relabel) for the same reason renderPodmanRun emits it (prx-3urm):
// on an SELinux-enforcing host — the common case for a production quadlet —
// the bare mount leaves the door dir `var_run_t` and the keeper hits EACCES
// creating its socket; `:z` relabels to `container_file_t`. Shared, not `:Z`,
// because the fabric is shared with the kube pod; a no-op on non-SELinux hosts.
lines.push(`Volume=${p.doorDir}:${p.doorDir}:z`);
// so this unit's exposed door is reachable across runtimes. Mounted at
// guestDoorDir (host-agnostic), NOT the host doorDir itself — see the
// matching note in renderPodmanRun (2026-07-03 fingerprinting finding).
// `:z` (shared SELinux relabel) for the same reason renderPodmanRun emits it
// (prx-3urm): on an SELinux-enforcing host — the common case for a
// production quadlet — the bare mount leaves the door dir `var_run_t` and
// the keeper hits EACCES creating its socket; `:z` relabels to
// `container_file_t`. Shared, not `:Z`, because the fabric is shared with
// the kube pod; a no-op on non-SELinux hosts.
lines.push(`Volume=${p.doorDir}:${p.guestDoorDir}:z`);
// The repo bind-mount + WorkingDir (mirrors renderPodmanRun; prx-u5lx). `:z`
// for the same SELinux reason — the repo is shared by every room.
if (p.repo) {
Expand Down
2 changes: 1 addition & 1 deletion packages/prx/test/room/concierged-room.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("conciergedRoom renders as a secret room (podman run --secret)", () =>
const i = argv.indexOf("--socket");
expect(i).toBeGreaterThanOrEqual(0);
expect(argv[i + 1]!.endsWith("/concierged.sock")).toBe(true);
expect(argv[i + 1]!.startsWith(pod.doorDir)).toBe(true);
expect(argv[i + 1]!.startsWith(pod.guestDoorDir)).toBe(true);
});

test("runs the concierged-box image", () => {
Expand Down
12 changes: 7 additions & 5 deletions packages/prx/test/room/pod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ import { z } from "zod";
import { perRepoPod } from "../../src/room/per-repo-pod.ts";
import {
DEFAULT_DOOR_DIR,
DEFAULT_GUEST_DOOR_DIR,
PodSpecSchema,
effectiveExecutor,
podRoomEnv,
resolvePodDoors,
type PodSpec,
} from "../../src/room/pod.ts";

// Socket paths are rebased to doorDir at projection time.
const beadsSock = `${DEFAULT_DOOR_DIR}/beadsd.sock`;
const keeperSock = `${DEFAULT_DOOR_DIR}/keeperd.sock`;
// Socket paths are rebased to guestDoorDir (the in-container mount path,
// host-agnostic by design) at projection time — NOT the host doorDir.
const beadsSock = `${DEFAULT_GUEST_DOOR_DIR}/beadsd.sock`;
const keeperSock = `${DEFAULT_GUEST_DOOR_DIR}/keeperd.sock`;
import { RoomSpecSchema } from "../../src/room/spec.ts";

// Author rooms as schema INPUT (tier/doors/grants default in, executor optional);
Expand Down Expand Up @@ -168,7 +170,7 @@ describe("podRoomEnv — the keystone", () => {
],
};
expect(podRoomEnv(pod([c, forgeDProvider]), "consumer")).toEqual({
PRX_FORGE_DOOR: `${DEFAULT_DOOR_DIR}/forge-d.sock`,
PRX_FORGE_DOOR: `${DEFAULT_GUEST_DOOR_DIR}/forge-d.sock`,
});
});

Expand Down Expand Up @@ -204,7 +206,7 @@ describe("perRepoPod", () => {
});

test("fires the gate env into claude-room (beadsd + keeperd + forge-d doors)", () => {
const dir = perRepoPod.doorDir;
const dir = perRepoPod.guestDoorDir;
expect(podRoomEnv(perRepoPod, "claude-room")).toEqual({
PRX_BEADS_DOOR: "beadsd",
PRX_BEADS_SOCKET: `${dir}/beadsd.sock`,
Expand Down
33 changes: 18 additions & 15 deletions packages/prx/test/room/podman.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ describe("renderPodmanKube", () => {
expect(manifest).toContain(`path: "${dir}"`);
expect(manifest).toContain("type: DirectoryOrCreate");
expect(manifest).not.toContain("emptyDir:");
// Every NON-SECRET room mounts it at the pod's doorDir.
const mountLine = `mountPath: "${dir}"`;
// Every NON-SECRET room mounts it at the pod's guestDoorDir — a
// host-agnostic path distinct from the host-side hostPath source above,
// so the guest can't fingerprint the host from its own mount table.
const mountLine = `mountPath: "${perRepoPod.guestDoorDir}"`;
const mounts = manifest.split(mountLine).length - 1;
expect(mounts).toBe(kubeRooms.length);
});
Expand Down Expand Up @@ -74,7 +76,7 @@ describe("renderPodmanKube", () => {
// it reaches the keeper on the shared fabric (the whole point of the split).
expect(manifest).toContain("PRX_KEEPER_DOOR");
expect(manifest).toContain(`value: "keeperd"`);
expect(manifest).toContain(`value: "${perRepoPod.doorDir}/keeperd.sock"`);
expect(manifest).toContain(`value: "${perRepoPod.guestDoorDir}/keeperd.sock"`);
});

test("falls back to a placeholder for a room with no image", () => {
Expand All @@ -91,23 +93,24 @@ describe("renderPodmanKube", () => {
// The beadsd consume↔expose pair resolves → claude-room gets the gate env.
expect(manifest).toContain("PRX_BEADS_DOOR");
expect(manifest).toContain(`value: "beadsd"`);
expect(manifest).toContain(`value: "${perRepoPod.doorDir}/beadsd.sock"`);
expect(manifest).toContain(`value: "${perRepoPod.guestDoorDir}/beadsd.sock"`);
});

test("overrides beadsd-room's --socket to the shared fabric path (prx-asr)", () => {
// The beadsd-box image bakes `--socket /run/prx/doors/beadsd.sock`; the kube
// container must override it to the mounted doorDir so the socket lands on
// the fabric consumers read (else beadsd serves off-fabric, unreachable).
// container must override it to the mounted guestDoorDir so the socket
// lands on the fabric consumers read (else beadsd serves off-fabric,
// unreachable).
expect(manifest).toContain("args:");
expect(manifest).toContain(`- "--socket"`);
expect(manifest).toContain(`- "${perRepoPod.doorDir}/beadsd.sock"`);
expect(manifest).toContain(`- "${perRepoPod.guestDoorDir}/beadsd.sock"`);
});

test("does NOT add a --socket override for claude-room's sealed control door", () => {
// claude-room exposes only `control` (state: closed) and its occupant is
// `claude`, which would choke on a stray --socket. Assert no control socket
// arg is emitted.
expect(manifest).not.toContain(`${perRepoPod.doorDir}/control.sock`);
expect(manifest).not.toContain(`${perRepoPod.guestDoorDir}/control.sock`);
});

test("does not emit env for a room with no wired door (beadsd-room)", () => {
Expand Down Expand Up @@ -197,7 +200,7 @@ describe("renderPodmanKube — backing services (prx-asr / dolt-box)", () => {
const slice = lines.slice(start).join("\n");
expect(slice).not.toContain("--socket");
// The dolt container mounts ONLY its data volume, not the door fabric.
expect(slice).not.toContain(`mountPath: "${svcPod.doorDir}"`);
expect(slice).not.toContain(`mountPath: "${svcPod.guestDoorDir}"`);
});

test("emits no persistentVolumeClaim when the pod has no services", () => {
Expand Down Expand Up @@ -237,8 +240,8 @@ describe("renderPodmanRun (prx-b44y — secret-holding rooms)", () => {
expect(i).toBeGreaterThanOrEqual(0);
// `:z` (shared) so an SELinux-enforcing host lets the keeper write its socket
// on the dir shared with the kube pod (prx-3urm); shared, not private `:Z`.
const dir = perRepoPod.doorDir;
expect(argv[i + 1]).toBe(`${dir}:${dir}:z`);
// Mounted at guestDoorDir (host-agnostic), not the host doorDir itself.
expect(argv[i + 1]).toBe(`${perRepoPod.doorDir}:${perRepoPod.guestDoorDir}:z`);
});

test("image precedes CMD args; CMD args override entrypoint socket + key (prx-9yv3)", () => {
Expand All @@ -250,7 +253,7 @@ describe("renderPodmanRun (prx-b44y — secret-holding rooms)", () => {
const cmdArgs = argv.slice(imageIdx + 1);
const socketIdx = cmdArgs.lastIndexOf("--socket");
expect(socketIdx).toBeGreaterThanOrEqual(0);
expect(cmdArgs[socketIdx + 1]).toBe(`${perRepoPod.doorDir}/keeperd.sock`);
expect(cmdArgs[socketIdx + 1]).toBe(`${perRepoPod.guestDoorDir}/keeperd.sock`);
const keyIdx = cmdArgs.lastIndexOf("--key");
expect(keyIdx).toBeGreaterThanOrEqual(0);
expect(cmdArgs[keyIdx + 1]).toBe("/run/secrets/keeper-key");
Expand Down Expand Up @@ -302,7 +305,7 @@ describe("renderPodmanRun (prx-b44y — secret-holding rooms)", () => {
// onto the shared fabric (not the in-box default /run/keeperd.sock).
expect(envPairs.some((e) => e.startsWith("KEEPERD_SOCK="))).toBe(true);
const keeperSockEnv = envPairs.find((e) => e.startsWith("KEEPERD_SOCK="));
expect(keeperSockEnv).toBe(`KEEPERD_SOCK=${perRepoPod.doorDir}/keeperd.sock`);
expect(keeperSockEnv).toBe(`KEEPERD_SOCK=${perRepoPod.guestDoorDir}/keeperd.sock`);
});

test("throws for a non-member room", () => {
Expand Down Expand Up @@ -341,8 +344,8 @@ describe("renderPodmanQuadlet (prx-b44y — production systemd form)", () => {
test("mounts the shared door fabric with a shared :z relabel (prx-3urm)", () => {
// `:z` so an SELinux-enforcing host (the common production case) lets the
// keeper write its socket on the shared door dir; shared, not private `:Z`.
const dir = perRepoPod.doorDir;
expect(lines).toContain(`Volume=${dir}:${dir}:z`);
// Mounted at guestDoorDir (host-agnostic), not the host doorDir itself.
expect(lines).toContain(`Volume=${perRepoPod.doorDir}:${perRepoPod.guestDoorDir}:z`);
});

test("borrows claude-box's hardening floor; keeps egress for the push", () => {
Expand Down
Loading