mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
fix(launch): Linux seats get the runtime directory, with a session bus address that can't connect (#2036)
On Linux, when the daemon has an absolute `XDG_RUNTIME_DIR`, a seat now gets it, together with `DBUS_SESSION_BUS_ADDRESS=disabled:`. Tools whose sockets live in the runtime directory (an MCP server's daemon, gpg-agent, rootless Podman) work in seats without per-tool setup, and the seat's D-Bus clients don't discover or autolaunch the session bus on their own. Refs #2030; reported by @borfast. `disabled:` was chosen from the libraries' sources: libdbus 1.16.2, GLib 2.88.0 and sd-bus 259.5 fall back to the runtime-directory bus, or autolaunch, only when the address is unset, and refuse an unknown transport before opening a socket. A Linux control with a throwaway bus confirmed it. This is default hygiene, not isolation. It applies to the scoped tmux launch (fresh launch, restore and handover successors) and the managed Claude launch. A seat's own `XDG_RUNTIME_DIR` (from `env:`, a one-off name or tmux `update-environment`) leaves both names as before, a seat's own address is kept, and an empty address is filled. Unchanged: `terminal.inherit_tmux_environment` on, macOS, and a daemon started without `XDG_RUNTIME_DIR`. Pi and OMP agent processes keep their fixed list. Changed behavior, accepted deliberately: - `systemctl --user` connects to `$XDG_RUNTIME_DIR/systemd/private` regardless of the bus address, so a seat can reach the person's user service manager. - A shell startup file that sets only `XDG_RUNTIME_DIR` used to let a classic seat's clients find the bus. Now the seat keeps `disabled:`. Set the address in the seat's `env:` to keep the bus. Known limit: Codex starts its stdio MCP servers with a fixed list of variables, so a Codex MCP entry still needs `env_vars = ["XDG_RUNTIME_DIR"]` in `config.toml`. Codex hooks and shell commands, and Claude Code's MCP servers and hooks, get it with no setup. Cost: source +56/-8 (`seat-runtime-dir.ts` new, `tmux.ts`, `startup.ts`, `claude-managed-launch.ts`), tests +114/-3, docs +10/-1. No new setting, refusal or dependency.
This commit is contained in:
1 parent
c241fbb45f
commit
4222e60804
10 files changed
+180
-12
No files matched your search
@@ -262,6 +262,9 @@ applies to future launches and successors, not running agents or your personal t
|
||||
If the scoped environment cannot be prepared, launch keeps its previous environment and
|
||||
reports a warning in fresh launch results and the daemon log. For handovers, check
|
||||
that log for this warning. This is not a promise that shell startup cannot add or override values.
|
||||
On Linux, a seat also gets the daemon's `XDG_RUNTIME_DIR`, so tools whose sockets live there work in seats,
|
||||
with `DBUS_SESSION_BUS_ADDRESS=disabled:` so its D-Bus clients don't find your session bus on their own. A seat's
|
||||
own `env:` value for either name wins. See "The runtime directory, on Linux" in the rig spec reference.
|
||||
|
||||
|
||||
## Start the kernel and check its state
|
||||
|
||||
@@ -428,13 +428,19 @@ pods:
|
||||
- A seat gets the rig's `env` with its own `env` laid over it, name by name.
|
||||
- The same environment is applied on first launch, on `rig up <rig-name>` restore, and to the successor of a seat handover.
|
||||
- `rig up <spec> --env KEY=VALUE` (repeatable) adds or overrides a value for one launch, except `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, which the CLI leaves out with a note (see "A seat's own account" below). A one-off value belongs to the terminal that launch creates: it stays in that terminal's environment for as long as the terminal lives, and OpenRig removes it from the tmux session's environment as soon as the terminal has started, so a terminal started later in that session does not get it. If OpenRig can't take it out, the seat keeps running and the launch warns, naming the variable and the tmux command to put it back or remove it. Until then, a terminal started later in that session would get it. A restore, a handover successor or any later launch starts from the spec's `env:` alone, so the value is not saved and does not carry forward. To keep a value, put it in `env:`. One exception to "does not carry forward", for a managed Claude seat (see below): a one-off name that the seat's spec does not declare stays on the session's list of names, `OPENRIG_SEAT_ENV_KEYS` (names only, no values), because the first launch and an in-place restart of the same terminal read that list. So if a later pane in that session gets a value for that name from the tmux server's own environment, its managed Claude launch can forward that value, where a launch without `--env` would leave it out. The first launch's one-off value itself is not passed on to that pane. This comes from reading the code and has not been measured. `--env` does not apply to `rig up <rig-name>` (a restore) or to a topology (several rigs from their own specs); the CLI refuses both. For a topology, put `env:` in each rig's spec.
|
||||
- **The runtime directory, on Linux.** When the daemon runs with an absolute `XDG_RUNTIME_DIR` (usually `/run/user/<uid>`), a seat gets the same `XDG_RUNTIME_DIR`, so a tool that keeps its socket there, such as an MCP server's daemon or `gpg-agent`, works inside the seat without per-tool setup. It comes with `DBUS_SESSION_BUS_ADDRESS=disabled:`, an address that D-Bus clients refuse. sd-bus, GDBus and libdbus look for your session bus at `$XDG_RUNTIME_DIR/bus`, or start one, only when that variable is unset, so a seat doesn't find your session bus (the keyring, notifications) on its own. This is a default, not isolation: `systemctl --user` reaches your service manager through its own socket in the runtime directory, as any tool there can.
|
||||
- A seat's own value wins. Set `DBUS_SESSION_BUS_ADDRESS` in `env:` to give a seat a bus. Set `XDG_RUNTIME_DIR` there to choose the directory yourself; the seat then gets no bus address, as before, so its D-Bus clients find the bus in that directory. A name listed in tmux's `update-environment` counts as the seat's own too.
|
||||
- A shell startup file that runs in the seat's terminal can still change either name. One that sets only `XDG_RUNTIME_DIR` now leaves the seat with `disabled:`, where before its clients found your bus; set the bus address in `env:` or in that file to keep it.
|
||||
- With `terminal.inherit_tmux_environment` on, a seat gets tmux's environment as before, and OpenRig adds neither name. On macOS, and for a daemon started without `XDG_RUNTIME_DIR`, nothing changes.
|
||||
- Codex passes only a fixed list of variables to the MCP servers it starts, so a Codex seat's MCP server gets the runtime directory only when its entry in `config.toml` names it: `env_vars = ["XDG_RUNTIME_DIR"]`. Codex's hooks and shell commands get it without that, and so do Claude Code's MCP servers and hooks. Pi and OMP agent processes don't get it (see below).
|
||||
- It applies at a seat's next launch, restore or handover; a seat that is up keeps the environment it started with.
|
||||
- Names starting `OPENRIG_` are reserved for OpenRig and refused. Empty values are refused, because the launch omits empty variables.
|
||||
- `rig export` writes the rig and member `env:` back out.
|
||||
|
||||
**Where the declared names reach, by runtime.** Declared values are handed to the seat's terminal as tmux environment, so most seats take them as given:
|
||||
|
||||
- Plain Claude Code (no permission mode selected), Codex and terminal seats take a spec `HOME`, `PATH` or `CLAUDE_CONFIG_DIR` as given.
|
||||
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. `XDG_RUNTIME_DIR` itself is not forwarded, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` and would reach the keyring and `systemctl --user`. `DBUS_SESSION_BUS_ADDRESS`, `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded either. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
|
||||
- The cleared environment (`env -i`) belongs only to a managed Claude launch, which is the one used when a permission mode is selected for the seat (for example `permission_policy: builtin:auto`). It forwards the declared names through the cleared environment, except `PATH`, `HOME` and `CLAUDE_CONFIG_DIR`, which OpenRig sets for that launch itself, and `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, for which it sets its own renderer setting. A spec value for any of those four is not forwarded as such; an absolute `CLAUDE_CONFIG_DIR` becomes the config dir OpenRig sets (see "A seat's own account" below). A seat that selected the shipped `openrig-core` plugin also has `CLAUDE_CODE_PLUGIN_DIRS` (the vendored plugin, added after a list the seat declares itself) among its declared names, so it is forwarded like the others and the plugin's hooks load in a seat with a permission mode as in one without; a seat that didn't select the plugin has no such name added. Beyond the declared names, the launch also forwards, by name and only when the seat's terminal has them set, the terminal and locale variables (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LANG` and the `LC_*` names) and the person's editor and display session: `EDITOR`, `VISUAL`, `DISPLAY`, `XAUTHORITY` and `WAYLAND_DISPLAY`. They let Claude's external-editor action open the person's editor and let image paste reach the clipboard, without a value in a spec. `WAYLAND_DISPLAY` is passed as an absolute path: an absolute value is kept, and a relative one is joined to the terminal's `XDG_RUNTIME_DIR`; if the terminal has no `XDG_RUNTIME_DIR` for it, the name is left out. The runtime directory and its `disabled:` bus address (see "The runtime directory, on Linux" above) are forwarded together, and the runtime directory is also forwarded beside a declared `DBUS_SESSION_BUS_ADDRESS`. A terminal's own `XDG_RUNTIME_DIR` or `DBUS_SESSION_BUS_ADDRESS`, from a shell startup file or tmux's `update-environment`, is not, because D-Bus clients look for the session bus at `$XDG_RUNTIME_DIR/bus` when no address is set; declare it in `env:` to forward it. `XDG_SESSION_TYPE`, `XDG_CURRENT_DESKTOP` and `SSH_AUTH_SOCK` are not forwarded. A running seat picks the names up at its next managed Claude launch, not before: `rig seat launch <seat> --fresh --stop --reason "<why>"` for that seat alone (a new conversation), or a restore of the whole rig (`rig down <rig>`, then `rig up <rig> --existing`, which resumes each conversation but restarts every seat in the rig), or a handover. A seat that is up keeps the environment it started with.
|
||||
- Codex seats run OpenRig's own Codex executable whatever a spec `PATH` says. They use the seat's own `CODEX_HOME` when its `env:` declares an absolute one, else the daemon's when the daemon has one.
|
||||
- On Pi and OMP seats the values reach the terminal. The agent process gets only what the runner passes on, so a spec or one-off value reaches it only for the names the runner always passes: `PATH`, `HOME`, `USER`, `LOGNAME`, `TERM`, `LANG`, `LC_ALL`, `SHELL` and `TMPDIR`. Pi's agent process receives the runner's `HOME`, so a spec or one-off `HOME` value that reaches the runner is passed to Pi; OMP's agent process gets its seat state directory (`$OPENRIG_HOME/state/omp/<seat>`, see Oh My Pi below) as `HOME`, so a `HOME` value does not reach it. The selected provider's variables are passed separately, as described under each runtime.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
|
||||
import type { ComposerSnapshot } from "../domain/composer-prompts.js";
|
||||
import { inspectComposerInput } from "../domain/session-transport.js";
|
||||
import { parentSessionEnvKeys, removeParentSessionEnv } from "../domain/parent-session-env.js";
|
||||
import { addSeatRuntimeDir } from "../domain/seat-runtime-dir.js";
|
||||
import type { TmuxControlExec } from "./tmux-control.js";
|
||||
|
||||
export type ExecFn = (cmd: string) => Promise<string>;
|
||||
@@ -369,7 +370,9 @@ function usableEnvLine(lines: readonly string[], name: string): boolean {
|
||||
|
||||
export class TmuxAdapter {
|
||||
/** Read for each new agent/successor. Unconfigured adapters retain legacy behavior. */
|
||||
launchEnvironment?: () => { inheritTmuxEnvironment: boolean; env: Readonly<Record<string, string | undefined>> };
|
||||
launchEnvironment?: () => { inheritTmuxEnvironment: boolean; env: Readonly<Record<string, string | undefined>>;
|
||||
/** The runtime directory a seat gets when it has none of its own (seat-runtime-dir.ts); none on macOS. */
|
||||
runtimeDir?: string };
|
||||
|
||||
/** Reset before the selected user shell runs, without changing the server or other sessions.
|
||||
* Multiple tmux command arguments execute directly; only this fixed non-interactive sh runs
|
||||
@@ -391,7 +394,10 @@ export class TmuxAdapter {
|
||||
.trim().split(/\s+/).filter(key => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key));
|
||||
const baseline = { ...Object.fromEntries(updated.map(key => [key, process.env[key]])), ...policy.env };
|
||||
removeParentSessionEnv(baseline);
|
||||
const values = Object.entries({ ...baseline, ...seatEnv })
|
||||
// A seat's own value (its env:, a one-off name, or a name in update-environment) wins over the runtime directory.
|
||||
const selected: Record<string, string | undefined> = { ...baseline, ...seatEnv };
|
||||
addSeatRuntimeDir(selected, policy.runtimeDir);
|
||||
const values = Object.entries(selected)
|
||||
.filter((entry): entry is [string,string] => entry[1] !== undefined && !["TMUX", "TMUX_PANE", "TERM", "SHELL"].includes(entry[0]))
|
||||
.map(([key,value]) => {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error("Invalid environment name");
|
||||
|
||||
@@ -12,6 +12,7 @@ import { claudeClassicRendererEnvPrefix } from "../adapters/yolo-mode.js";
|
||||
import { parseClaudePermissionModes } from "./permission-drift.js";
|
||||
import { validateNativePermissionSelection } from "./native-permission-selection.js";
|
||||
import { seatLaunchAdditionsForLaunch } from "./seat-launch-additions.js";
|
||||
import { NO_SESSION_BUS } from "./seat-runtime-dir.js";
|
||||
import { effectiveRuntimeHome } from "./seat-runtime-home.js";
|
||||
import { claudeSeatTokenFallbackNotice, claudeSeatTokenFileProblem, claudeSeatTokenStep, readClaudeSeatTokenRef, type ClaudeSeatTokenRef } from "./claude-seat-token.js";
|
||||
import { OUTRANKING_TOKEN_ENV, projectRootForLocalSettings, settingsFileConflicts, type CredentialConflict, type CredentialPrecedence } from "./claude-credential-precedence.js";
|
||||
@@ -59,10 +60,10 @@ const PANE_TERMINAL_KEYS = ["TERM", "COLORTERM", "TERM_PROGRAM", "TERM_PROGRAM_V
|
||||
/** The person's editor and display session, so a managed seat behaves like a plain one for Claude's external-editor
|
||||
* action (EDITOR, VISUAL; Claude falls back to VS Code without them) and for image paste, which runs
|
||||
* `xclip || wl-paste` (DISPLAY with XAUTHORITY reach X11; WAYLAND_DISPLAY reaches the compositor, see below). Names only:
|
||||
* the pane supplies the values. Left out: XDG_RUNTIME_DIR, because sd-bus, GDBus and libdbus fall back to
|
||||
* $XDG_RUNTIME_DIR/bus when DBUS_SESSION_BUS_ADDRESS is unset, so it would hand the seat the session bus (keyring,
|
||||
* `systemctl --user`) that the cleared environment keeps out; and DBUS_SESSION_BUS_ADDRESS, XDG_SESSION_TYPE and
|
||||
* XDG_CURRENT_DESKTOP, which neither path reads. */
|
||||
* the pane supplies the values. Left out: XDG_SESSION_TYPE and XDG_CURRENT_DESKTOP, which neither path reads. A pane's
|
||||
* XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS are forwarded only as OpenRig's own pair (seat-runtime-dir.ts, below):
|
||||
* sd-bus, GDBus and libdbus fall back to $XDG_RUNTIME_DIR/bus when the address is unset, so a runtime directory alone
|
||||
* would hand the seat the session bus (the keyring, notifications) that the cleared environment keeps out. */
|
||||
const PANE_SESSION_KEYS = ["EDITOR", "VISUAL", "DISPLAY", "XAUTHORITY"];
|
||||
|
||||
/** WAYLAND_DISPLAY without XDG_RUNTIME_DIR: a client resolves a relative socket name against XDG_RUNTIME_DIR, but an
|
||||
@@ -319,6 +320,16 @@ export class ClaudeManagedLaunch {
|
||||
const otherwise = (key: string) => selectedKeys.includes(key) ? "" : ` else unset ${key};`;
|
||||
const terminal = terminalKeys.map(key => `if [ -n "\${${key}-}" ]; then export ${key};${otherwise(key)} fi`);
|
||||
terminal.push(`if [ -n "$${WAYLAND_DISPLAY_VAR}" ]; then export WAYLAND_DISPLAY="$${WAYLAND_DISPLAY_VAR}";${otherwise("WAYLAND_DISPLAY")} fi`);
|
||||
// The runtime directory reaches Claude when the pane has OpenRig's pair (the directory and the bus address that can't
|
||||
// connect), or beside the seat's own declared bus address. A pane's own runtime directory or bus address, from a shell
|
||||
// profile or tmux's update-environment, stays out as before. Declared names are forwarded above as they are.
|
||||
const ownBus = selectedKeys.includes("DBUS_SESSION_BUS_ADDRESS");
|
||||
const openRigBus = `[ "\${DBUS_SESSION_BUS_ADDRESS-}" = ${shellQuote(NO_SESSION_BUS)} ]`;
|
||||
const runtimeDir = [
|
||||
...(selectedKeys.includes("XDG_RUNTIME_DIR") ? [] : [`if [ -n "\${XDG_RUNTIME_DIR-}" ] && `
|
||||
+ `${ownBus ? '[ -n "${DBUS_SESSION_BUS_ADDRESS-}" ]' : openRigBus}; then export XDG_RUNTIME_DIR; else unset XDG_RUNTIME_DIR; fi`]),
|
||||
...(ownBus ? [] : [`if ${openRigBus}; then export DBUS_SESSION_BUS_ADDRESS; else unset DBUS_SESSION_BUS_ADDRESS; fi`]),
|
||||
];
|
||||
// A seat's own subscription token (rig provider switch --seat) is read from its file as Claude starts, after the
|
||||
// removals are worked out, so the value never passes through OpenRig. The reduction keeps the name the step sets; when the
|
||||
// file can't be read, Claude still starts, and a token the reduction would have removed is removed by the step instead.
|
||||
@@ -329,7 +340,8 @@ export class ClaudeManagedLaunch {
|
||||
return Object.freeze({ assertCurrent, warnings, configDir: context.configDir, executable: context.executable, executableIsScript: context.executableIsScript, command: (args: readonly string[], launchEnv: Readonly<Record<string, string>> = {}) => {
|
||||
assertCurrent();
|
||||
const launch = Object.entries(launchEnv).map(([key, value]) => `export ${shellQuote(`${key}=${value}`)}`);
|
||||
const keep = [...selectedKeys, ...terminalKeys, "WAYLAND_DISPLAY", ...Object.keys(launchEnv), ...(token ? ["CLAUDE_CODE_OAUTH_TOKEN"] : [])];
|
||||
const keep = [...selectedKeys, ...terminalKeys, "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS",
|
||||
...Object.keys(launchEnv), ...(token ? ["CLAUDE_CODE_OAUTH_TOKEN"] : [])];
|
||||
// env -0 keeps multiline values out of the name parser. The existing daemon's Node reads
|
||||
// that pipe with an empty environment (no pane NODE_OPTIONS); only removal names reach argv.
|
||||
const removalArgs = `const data=require("node:fs").readFileSync(0,"utf8"); if(!data.endsWith("\\0"))process.exit(1); `
|
||||
@@ -337,7 +349,7 @@ export class ClaudeManagedLaunch {
|
||||
+ `process.stdout.write("set -- "+data.split("\\0").filter(s=>s.includes("=")).map(s=>s.slice(0,s.indexOf("="))).filter(k=>!keep.has(k)).flatMap(k=>["-u",quote(k)]).join(" "));`;
|
||||
const clear = `__openrig_remove=$(/usr/bin/env -0 | /usr/bin/env -i ${shellQuote(process.execPath)} -e ${shellQuote(removalArgs)}) && eval "$__openrig_remove"`;
|
||||
return `${WAYLAND_DISPLAY_ASSIGNMENT}; cd ${shellQuote(cwd)} && {
|
||||
${[...assignments, ...forwarded, ...seatForwarded, ...projectForwarded, ...terminal, ...launch].join("\n")}
|
||||
${[...assignments, ...forwarded, ...seatForwarded, ...projectForwarded, ...terminal, ...runtimeDir, ...launch].join("\n")}
|
||||
${clear} && ${tokenStep}exec /usr/bin/env "$@" ${[shellQuote(context.executable), ...args.map(shellQuote)].join(" ")}
|
||||
}`;
|
||||
} });
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from "node:path";
|
||||
|
||||
/** A session bus address that can't connect. On Linux a seat gets the daemon's XDG_RUNTIME_DIR, so tools that keep their
|
||||
* socket there (an MCP server's daemon, gpg-agent, rootless Podman) work in seats without per-tool setup. sd-bus, GDBus
|
||||
* and libdbus look for the session bus at $XDG_RUNTIME_DIR/bus, or autolaunch one, only when DBUS_SESSION_BUS_ADDRESS
|
||||
* is unset, so this address comes with it: a seat doesn't find the person's session bus (the keyring, notifications)
|
||||
* on its own. It's default hygiene, not isolation. A seat that sets an address still reaches that bus, and
|
||||
* `systemctl --user` reaches the service manager through its own socket in the runtime directory, like any tool there.
|
||||
* An unknown transport, so all three refuse it before opening a socket (an empty value is unset to libdbus). */
|
||||
export const NO_SESSION_BUS = "disabled:";
|
||||
|
||||
/** The runtime directory OpenRig hands seats: the daemon's own, on Linux only, when it's an absolute path. macOS
|
||||
* usually has none, and a daemon started without one hands none. */
|
||||
export function seatRuntimeDir(env: Readonly<Record<string, string | undefined>>, platform: NodeJS.Platform = process.platform): string | undefined {
|
||||
const dir = env.XDG_RUNTIME_DIR;
|
||||
return platform === "linux" && dir && path.isAbsolute(dir) ? dir : undefined;
|
||||
}
|
||||
|
||||
/** Lay the runtime directory and the bus address that can't connect under a seat's launch environment, in place. A
|
||||
* value the seat already has wins. Its own XDG_RUNTIME_DIR leaves both names as they are, so a seat that chose its
|
||||
* runtime directory keeps finding its bus there as before; its own DBUS_SESSION_BUS_ADDRESS is kept beside the added
|
||||
* directory. An empty address isn't one: libdbus reads it as unset and would find the bus in the added directory. */
|
||||
export function addSeatRuntimeDir(env: Record<string, string | undefined>, runtimeDir: string | undefined): void {
|
||||
if (!runtimeDir || env.XDG_RUNTIME_DIR !== undefined) return;
|
||||
env.XDG_RUNTIME_DIR = runtimeDir;
|
||||
if (!env.DBUS_SESSION_BUS_ADDRESS) env.DBUS_SESSION_BUS_ADDRESS = NO_SESSION_BUS;
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { ClaudeResumeAdapter } from "./adapters/claude-resume.js";
|
||||
import { CodexResumeAdapter } from "./adapters/codex-resume.js";
|
||||
import { codexDaemonSupportProbe } from "./domain/codex-daemon-support.js";
|
||||
import { claudeConfigRootFor, declaredRuntimeHomes, seatRuntimeHome } from "./domain/seat-runtime-home.js";
|
||||
import { seatRuntimeDir } from "./domain/seat-runtime-dir.js";
|
||||
import { codexNetworkDefaultReader } from "./domain/codex-network-default.js";
|
||||
import { codexUsageReader } from "./domain/provider/codex-usage-reader.js";
|
||||
import { CodexUsageWindows } from "./domain/provider/codex-usage-windows.js";
|
||||
@@ -670,6 +671,8 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// Ordinary shell/config roots remain daemon-selected; seat overrides are merged by the adapter.
|
||||
env: { ...Object.fromEntries(["LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "CLAUDE_CONFIG_DIR"]
|
||||
.map(key => [key, process.env[key]])), ...launchSessionEnv },
|
||||
// #2030: on Linux, the daemon's runtime directory, with a session bus address that can't connect.
|
||||
runtimeDir: seatRuntimeDir(process.env),
|
||||
});
|
||||
const tmuxOptionDefaults = new TmuxOptionDefaultsApplier({
|
||||
tmuxAdapter,
|
||||
|
||||
@@ -11,6 +11,9 @@ import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
|
||||
import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { seatLifecycleService } from "../src/routes/seat.js";
|
||||
import type { NodeBinding } from "../src/domain/runtime-adapter.js";
|
||||
import { NO_SESSION_BUS } from "../src/domain/seat-runtime-dir.js";
|
||||
import { SEAT_ENV_KEYS_VAR } from "../src/domain/seat-env.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
|
||||
// No provider, tmux or startup. Config-selection cases execute only a private
|
||||
// fake Claude through the generated shell command; other child calls are mocked.
|
||||
@@ -204,7 +207,8 @@ else console.log(JSON.stringify(Object.fromEntries(['TMUX', 'TERM_PROGRAM', 'TER
|
||||
// without a per-spec value. WAYLAND_DISPLAY is resolved to an absolute path instead of forwarding XDG_RUNTIME_DIR.
|
||||
const sessionNames = ["EDITOR", "VISUAL", "DISPLAY", "XAUTHORITY"];
|
||||
// Present in the pane on purpose: they must stay out. XDG_RUNTIME_DIR is where sd-bus, GDBus and libdbus look for the
|
||||
// session bus when DBUS_SESSION_BUS_ADDRESS is unset; the bus and desktop names reach services (keyring, notifications,
|
||||
// session bus when DBUS_SESSION_BUS_ADDRESS is unset, so the pane's own pair stays out; only OpenRig's pair, with the
|
||||
// bus address that can't connect, is forwarded (#2030, below). The desktop names reach services (notifications,
|
||||
// xdg-open) that nothing in the editor or paste path uses; SSH_AUTH_SOCK is a credential channel.
|
||||
const withheldNames = ["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "SSH_AUTH_SOCK", "TMUX", "TMUX_PANE"];
|
||||
const paneNames = [...sessionNames, "WAYLAND_DISPLAY", ...withheldNames];
|
||||
@@ -284,9 +288,35 @@ else console.log(JSON.stringify(Object.fromEntries(${JSON.stringify(paneNames)}
|
||||
const cmd = f.calls[0]!;
|
||||
for (const key of sessionNames) expect(cmd).toContain(`if [ -n "\${${key}-}" ]; then export ${key}; else unset ${key}; fi`);
|
||||
expect(cmd).toContain('export WAYLAND_DISPLAY="$__openrig_wayland_display"');
|
||||
// XDG_RUNTIME_DIR is read to resolve WAYLAND_DISPLAY, never forwarded as a variable.
|
||||
// XDG_RUNTIME_DIR resolves WAYLAND_DISPLAY, and it and the bus address are forwarded only as OpenRig's pair.
|
||||
expect(cmd).not.toMatch(/XDG_RUNTIME_DIR=\$|"XDG_RUNTIME_DIR=/);
|
||||
for (const key of withheldNames.filter(key => key !== "XDG_RUNTIME_DIR")) expect(cmd).not.toContain(key);
|
||||
expect(cmd).toContain(`if [ "\${DBUS_SESSION_BUS_ADDRESS-}" = ${shellQuote(NO_SESSION_BUS)} ]; then export DBUS_SESSION_BUS_ADDRESS; else unset DBUS_SESSION_BUS_ADDRESS; fi`);
|
||||
for (const key of withheldNames.filter(key => !["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"].includes(key))) expect(cmd).not.toContain(key);
|
||||
});
|
||||
// #2030: a pane with OpenRig's runtime-dir pair hands it to Claude; a pane's own runtime directory or bus address, from a
|
||||
// shell profile or tmux's update-environment, stays out as before.
|
||||
const openRigPair = { XDG_RUNTIME_DIR: "/run/user/1000", DBUS_SESSION_BUS_ADDRESS: NO_SESSION_BUS };
|
||||
const runtimeDirCases: [string, Record<string, string>, Record<string, string>][] = [
|
||||
["OpenRig's pair", openRigPair, openRigPair],
|
||||
["OpenRig's bus address with no runtime directory", { DBUS_SESSION_BUS_ADDRESS: NO_SESSION_BUS }, { DBUS_SESSION_BUS_ADDRESS: NO_SESSION_BUS }],
|
||||
["a runtime directory alone", { XDG_RUNTIME_DIR: "/run/user/1000" }, {}],
|
||||
["a runtime directory with the pane's own bus address", { XDG_RUNTIME_DIR: "/run/user/1000", DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus" }, {}],
|
||||
];
|
||||
it.each(shells.flatMap(shell => runtimeDirCases.map(([label, pane, expected]) => [label, shell, pane, expected] as const)))(
|
||||
"hands Claude the runtime directory only with OpenRig's bus address: %s under %s", async (_label, shell, pane, expected) => {
|
||||
const unset = ["WAYLAND_DISPLAY", ...["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"].filter(key => !(key in pane))];
|
||||
expect(await runPane(fixture(), { ...withheldInPane, ...pane }, unset, shell)).toEqual(expected);
|
||||
});
|
||||
it.each([
|
||||
["own bus address", "DBUS_SESSION_BUS_ADDRESS", { XDG_RUNTIME_DIR: "/run/user/1000", DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus" },
|
||||
{ XDG_RUNTIME_DIR: "/run/user/1000", DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus" }],
|
||||
["own runtime directory", "XDG_RUNTIME_DIR", { XDG_RUNTIME_DIR: "/run/user/1000" }, { XDG_RUNTIME_DIR: "/run/user/1000" }],
|
||||
])("forwards a seat's %s as declared, with the runtime directory beside a declared bus address", async (_label, declared, pane, expected) => {
|
||||
const f = fixture();
|
||||
// A one-off launch name, read from the session, is a declared name like an env: entry.
|
||||
const managed = new ClaudeManagedLaunch(f.db, f.env, f.renderer, async (_session, key) => key === SEAT_ENV_KEYS_VAR ? declared : undefined);
|
||||
const unset = ["WAYLAND_DISPLAY", ...["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"].filter(key => !(key in pane))];
|
||||
expect(await runPane({ ...f, managed }, { ...withheldInPane, ...pane }, unset)).toEqual(expected);
|
||||
});
|
||||
it("detects unset config becoming explicit even when the storage directory stays the same", async () => {
|
||||
const f = fixture(); delete f.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// #2030: the runtime directory a seat gets on Linux, and which of a seat's own values win over it.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { NO_SESSION_BUS, addSeatRuntimeDir, seatRuntimeDir } from "../src/domain/seat-runtime-dir.js";
|
||||
|
||||
describe("seatRuntimeDir", () => {
|
||||
it.each([
|
||||
["linux", { XDG_RUNTIME_DIR: "/run/user/1000" }, "/run/user/1000"],
|
||||
["linux", {}, undefined],
|
||||
["linux", { XDG_RUNTIME_DIR: "" }, undefined],
|
||||
["linux", { XDG_RUNTIME_DIR: "run/user/1000" }, undefined],
|
||||
["darwin", { XDG_RUNTIME_DIR: "/run/user/1000" }, undefined],
|
||||
] as const)("on %s with %j hands seats %s", (platform, env, expected) => {
|
||||
expect(seatRuntimeDir(env, platform)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSeatRuntimeDir", () => {
|
||||
it.each([
|
||||
["nothing of its own", {}, { XDG_RUNTIME_DIR: "/run/user/1000", DBUS_SESSION_BUS_ADDRESS: NO_SESSION_BUS }],
|
||||
["its own runtime directory", { XDG_RUNTIME_DIR: "/seat" }, { XDG_RUNTIME_DIR: "/seat" }],
|
||||
["its own empty runtime directory", { XDG_RUNTIME_DIR: "" }, { XDG_RUNTIME_DIR: "" }],
|
||||
// libdbus reads an empty address as unset, so it would find the bus in the added directory.
|
||||
["an empty bus address", { DBUS_SESSION_BUS_ADDRESS: "" }, { XDG_RUNTIME_DIR: "/run/user/1000", DBUS_SESSION_BUS_ADDRESS: NO_SESSION_BUS }],
|
||||
["its own bus address", { DBUS_SESSION_BUS_ADDRESS: "unix:path=/seat/bus" },
|
||||
{ XDG_RUNTIME_DIR: "/run/user/1000", DBUS_SESSION_BUS_ADDRESS: "unix:path=/seat/bus" }],
|
||||
["both of its own", { XDG_RUNTIME_DIR: "/seat", DBUS_SESSION_BUS_ADDRESS: "unix:path=/seat/bus" },
|
||||
{ XDG_RUNTIME_DIR: "/seat", DBUS_SESSION_BUS_ADDRESS: "unix:path=/seat/bus" }],
|
||||
] as const)("a seat with %s keeps it", (_label, seat, expected) => {
|
||||
const env: Record<string, string | undefined> = { PATH: "/bin", ...seat };
|
||||
addSeatRuntimeDir(env, "/run/user/1000");
|
||||
expect(env).toEqual({ PATH: "/bin", ...expected });
|
||||
});
|
||||
|
||||
it("adds nothing when there's no runtime directory to hand on", () => {
|
||||
const env: Record<string, string | undefined> = { PATH: "/bin" };
|
||||
addSeatRuntimeDir(env, undefined);
|
||||
expect(env).toEqual({ PATH: "/bin" });
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { execFileSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createDaemon } from "../src/startup.js";
|
||||
import { seatRuntimeDir } from "../src/domain/seat-runtime-dir.js";
|
||||
import type { AppDeps } from "../src/server.js";
|
||||
import type { CmuxTransportFactory } from "../src/terminal/cmux-transport.js";
|
||||
import type { ExecFn } from "../src/domain/tmux-adapter.js";
|
||||
@@ -89,6 +90,9 @@ describe("P16 — AppDeps members with safety semantics are composed (definednes
|
||||
});
|
||||
|
||||
describe("P16 — domain-internal safety injections (the recurred class, pinned at the wire)", () => {
|
||||
it("new seats receive the daemon's runtime directory default (#2030)", () => {
|
||||
expect(deps.tmuxAdapter.launchEnvironment!()).toHaveProperty("runtimeDir", seatRuntimeDir(process.env));
|
||||
});
|
||||
it("new seats receive the selected database and configured tmux directory, not a stale DB override", () => {
|
||||
const env = deps.tmuxAdapter.launchEnvironment!().env;
|
||||
expect(env.OPENRIG_DB).toBe(path.resolve(db.name));
|
||||
|
||||
@@ -9,6 +9,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { SeatLaunchEnvironment } from "../src/domain/seat-launch-environment.js";
|
||||
import { shellQuote } from "../src/adapters/shell-quote.js";
|
||||
import { NO_SESSION_BUS } from "../src/domain/seat-runtime-dir.js";
|
||||
const exec = promisify(execFile);
|
||||
const waitFile = async (file: string) => {
|
||||
// Redirection creates the file before printf writes it; the final newline marks completion.
|
||||
@@ -164,6 +165,43 @@ describe.skipIf(process.platform === "win32")("#1178 initial-shell environment p
|
||||
} finally { warn.mockRestore(); await tmux(["kill-server"]).catch(() => {}); rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }); }
|
||||
});
|
||||
|
||||
// #2030: a seat gets the runtime directory with the bus address that can't connect, unless it has its own value.
|
||||
it.each([
|
||||
["the runtime directory and the bus address", "create", {}, undefined, false, "/run/daemon-dir", ["/run/daemon-dir", NO_SESSION_BUS]],
|
||||
["the same for a handover successor's pane", "respawn", {}, undefined, false, "/run/daemon-dir", ["/run/daemon-dir", NO_SESSION_BUS]],
|
||||
["its own runtime directory, and no bus address", "create", { XDG_RUNTIME_DIR: "/seat/dir" }, undefined, false, "/run/daemon-dir", ["/seat/dir", "absent"]],
|
||||
["its own bus address beside the runtime directory", "create", { DBUS_SESSION_BUS_ADDRESS: "unix:path=/seat/bus" }, undefined, false, "/run/daemon-dir", ["/run/daemon-dir", "unix:path=/seat/bus"]],
|
||||
["a runtime directory from update-environment, and no bus address", "create", {}, "/updated/dir", false, "/run/daemon-dir", ["/updated/dir", "absent"]],
|
||||
["the tmux environment unchanged when it is inherited", "create", {}, undefined, true, "/run/daemon-dir", ["/server/dir", "unix:path=/server/bus"]],
|
||||
["neither name when the daemon has no runtime directory", "create", {}, undefined, false, undefined, ["absent", "absent"]],
|
||||
] as const)("gives a seat %s", async (_label, mode, seatEnv, updated, inherited, runtimeDir, expected) => {
|
||||
const home=mkdtempSync(path.join(tmpdir(),"tmux-runtime-dir-")),socket=path.join(home,"owned.sock"),observed=path.join(home,"observed");
|
||||
vi.stubEnv("XDG_RUNTIME_DIR",updated);vi.stubEnv("DBUS_SESSION_BUS_ADDRESS",undefined);
|
||||
const env={...process.env,HOME:home};delete env.TMUX;delete env.TMUX_TMPDIR;
|
||||
const tmux=async(args:string[])=>(await exec("tmux",["-S",socket,...args],{env})).stdout.trim();
|
||||
const adapter=new TmuxAdapter(async()=>"",undefined,args=>tmux(args.slice(1)));
|
||||
adapter.launchEnvironment=()=>({inheritTmuxEnvironment:inherited,env:{HOME:home,PATH:process.env.PATH!},runtimeDir});
|
||||
try {
|
||||
await tmux(["-f","/dev/null","new-session","-d","-s","anchor","sleep 60"]);
|
||||
await tmux(["set-environment","-g","XDG_RUNTIME_DIR","/server/dir"]);
|
||||
await tmux(["set-environment","-g","DBUS_SESSION_BUS_ADDRESS","unix:path=/server/bus"]);
|
||||
if (updated) await tmux(["set-option","-g","update-environment","XDG_RUNTIME_DIR"]);
|
||||
await tmux(["set-option","-g","default-shell","/bin/bash"]);
|
||||
const script=path.join(home,"observe.sh");
|
||||
writeFileSync(script,`export HISTFILE=/dev/null\nprintf '%s|%s\\n' "\${XDG_RUNTIME_DIR-absent}" "\${DBUS_SESSION_BUS_ADDRESS-absent}" > ${shellQuote(observed)}\nexec /bin/bash --noprofile --norc\n`);
|
||||
await tmux(["set-option","-g","default-command",`exec /bin/bash ${shellQuote(script)}`]);
|
||||
const selected={OPENRIG_NODE_ID:"owned-node",...seatEnv};
|
||||
if (mode==="respawn") {
|
||||
await tmux(["new-session","-d","-s","seat","sleep 60"]);
|
||||
await tmux(["set-window-option","-t","=seat:","remain-on-exit","on"]);
|
||||
await tmux(["respawn-pane","-k","-t","=seat:","/bin/sh","-c","exit"]);
|
||||
for(let i=0;i<100 && await tmux(["display-message","-p","-t","=seat:","#{pane_dead}"])!=="1";i++) await new Promise(r=>setTimeout(r,25));
|
||||
expect((await adapter.respawnPane(await tmux(["display-message","-p","-t","=seat:","#{pane_id}"]),undefined,{cwd:home,env:selected})).ok).toBe(true);
|
||||
} else expect((await adapter.createSession("seat",home,selected)).ok).toBe(true);
|
||||
expect(await waitFile(observed)).toEqual(expected);
|
||||
} finally {await tmux(["kill-server"]).catch(()=>{});vi.unstubAllEnvs();rmSync(home,{recursive:true,force:true,maxRetries:5,retryDelay:20});}
|
||||
});
|
||||
|
||||
it.each(["create","respawn"])("returns the scoped-environment fallback warning to %s callers",async mode=>{
|
||||
const adapter=new TmuxAdapter(async()=>"",undefined,async args=>{
|
||||
if(args.includes("default-shell"))throw Error("unavailable inspection");
|
||||
|
||||
Reference in new issue
Block a user