feat(daemon): isolate managed tmux sessions per instance (#1260)

OpenRig's managed seats can now run on a tmux server of their own instead of sharing the person's default server. `rig config set terminal.tmux_server isolated` keeps them on a persistent server derived from the canonical OpenRig home, so they stop cluttering the person's own `tmux ls`. `inherit` remains the default and keeps the existing native server selection and attachment behaviour; both modes keep normal tmux configuration. The selection is made before reconciliation, and a change waits while this instance's registered seats still run on the previous server, or while that server can't be inspected; it never moves or kills a live pane. Launch, capture, inventory, terminal views and cleanup use the effective selection. Ordinary inheritance tolerates missing or invalid advisory records and gains no new identity prerequisite; strict record and identity checks apply to isolation. Fixed isolated and migration selections carry a durable pin, and an opt-out records it before removing the marker, so startup and offline cleanup agree after an interrupted update. SSH tiles follow the viewing instance's effective mode: inherited views keep `ssh -t … tmux attach`, and isolated views use the remote `rig terminal attach` and never fall back to a same-named session on the default server. The reference includes a two-instance recipe with separate homes, databases and ports.
This commit is contained in:
Nash Aschenbath authored and GitHub committed 2026-10-30 03:39:28 -07:00
1 parent 18e3446a06
commit 371a2f3916
66 files changed
+2546 -160

No files matched your search

@@ -398,6 +398,33 @@ adapter never closes a session on it and reports `rex_outcome_unknown`.
Herdr and Rex build their layout trees from one grid helper
(`buildTileGrid`, `tile-layout.ts`).
### Tmux server selection
Before reconciliation, `startup.ts` calls `selectTmuxServer` in
`domain/tmux-server-selection.ts`. `terminal.tmux_server` defaults to `inherit`;
`isolated` selects a socket derived from the canonical OpenRig home. The effective
choice is persisted in `tmux-server.json`. A registered live seat, or an
uninspectable old server, defers a requested change until a later restart.
The persisted `pinned` flag and active marker make isolation and migration records
mandatory, while ordinary inherited records remain advisory. Returning to inheritance
commits an unpinned record before clearing the marker, so offline cleanup cannot reuse
a stale inherited socket or keep enforcing a completed isolation choice.
`TmuxAdapter` receives the selection's `executionArgs` for both shell and argv
transports, including its direct probe/cleanup calls. They are empty for ordinary
`inherit` mode, preserving tmux's environment-based selection; `serverArgs` still
names the server for attachments. The opt-in control client uses the same execution
arguments for its connection, probes and fallback commands. The same startup choice feeds node
inventory, `TerminalService.status()` and previews, composed panes, shared TUI attachment and
CLI viewer management. Inherited SSH panes retain the remote-tmux replacement
wrapper and plain recovery command without requiring remote OpenRig. Isolated
SSH panes resolve the remote instance through its own `rig terminal attach`.
For isolation, self-attachment checks the caller's socket before binding;
offline destruction reads the persisted socket before removing state.
See [tmux server selection](../../reference/tmux-servers.md) for configuration
loading, first-upgrade migration, discovery scope and platform behavior.
## 3. Resume honesty
The daemon does not assume a harness resumed just because the launch command
+22
View File
@@ -168,6 +168,28 @@ Exit codes: 0 pass, 1 a leg failed, 2 lane busy, 3 the runner itself errored.
### Host mode: while you're writing a scenario
The tmux server selection regression also uses these scaffold and lifecycle helpers
in `packages/daemon/test/tmux-server-isolation-daemon.test.ts`. It runs a real stub
seat through config changes, daemon restarts, capture, self-attachment and cleanup,
while native socket observations verify that a personal namesake survives. The
test also removes the effective-server record and verifies that `destroy --all`
leaves the daemon, database and both servers' sessions intact before restoring
the record and checking normal cleanup. The
YAML grammar has no config-write or socket-inventory surface, so these assertions
live in the Vitest integration test. It does not exercise a real provider model,
SSH connection or graphical terminal window.
`packages/daemon/test/tmux-server-two-instances.test.ts` adds two real daemons
sharing a scratch OS home and inherited personal tmux server, with separate
OpenRig homes, databases and ports. Both enable isolation and control mode. It
checks same-named seats through inventory, capture and verified writes, observes
the marked control session on each selected socket, then stops one instance and
checks that the other instance's pane identity, control session and delivery still
work. The personal namesake remains untouched. This test requires a tmux version
that supports control mode and the built CLI and daemon. Like CI's package suites,
it should run with scratch homes and outbound network restricted to localhost;
scripted seats prove terminal routing and rendered delivery, not model receipt.
```sh
npm run build # the runner drives packages/cli/dist/bin-wrapper.js
node --import tsx packages/daemon/scripts/run-scenarios.mjs # the fixture scenario-*.yaml files
+6 -1
View File
@@ -509,7 +509,12 @@ rig terminal open saved:kernel --provider herdr --json
rig terminal open saved:kernel --provider cmux --json
```
Use `rig terminal status --json` to inspect provider availability and liveness.
Use `rig terminal status --json` to inspect provider availability, liveness and the
daemon's effective tmux server. To keep managed seats out of your personal tmux
session list, opt into [an isolated server](tmux-servers.md) with
`rig config set terminal.tmux_server isolated`; existing live seats defer the
switch until they are stopped and the daemon restarts.
To keep rigs in separate Herdr sessions, select one explicitly:
`rig terminal open future-prod-rig --provider herdr --session future-prod`.
`--session default` selects Herdr's default session; omitting the option keeps
+162
View File
@@ -0,0 +1,162 @@
# Choosing the tmux server for an OpenRig instance
`terminal.tmux_server` controls where the daemon creates and reads managed seats.
It accepts `inherit` (the default) or `isolated`. The environment override is
`OPENRIG_TMUX_SERVER`.
To keep managed seats out of your personal `tmux ls`, set this before starting a
new instance, or before a deliberate restart of an existing daemon:
```sh
rig config set terminal.tmux_server isolated
```
The setting is read once at daemon startup. Changing it does not move a running
seat. Inspect the effective choice with:
```sh
rig terminal status --json
```
The `tmux` object reports `requestedMode` (read at the last startup), effective
`mode`, `socketPath`, `serverArgs`, `migrationPending`, and `blockingSessions`.
The settings browser shows the configured value; terminal status shows what the
running daemon actually uses.
## Server identity and configuration
In `inherit` mode, a new instance uses the launching shell's `TMUX` socket, or the
default socket under `TMUX_TMPDIR` (otherwise `/tmp`) when outside tmux. Native
commands retain tmux's existing environment-based selection. Printed attachments
and terminal metadata name a non-default server so a new terminal can reach it.
The default mode does not require a server record or an additional instance
identity check to start the daemon or use existing attachment paths.
In `isolated` mode, the socket is named `openrig-<hash>` under tmux's per-user
directory. The hash identifies the canonical `OPENRIG_HOME`: different homes get
different servers, and symlink aliases of one home share a server. Choose a short
`TMUX_TMPDIR` before the first isolated start if your normal temporary path would
exceed the platform's Unix socket path limit.
When isolation is requested or active, the daemon records the effective socket in
`OPENRIG_HOME/tmux-server.json` using an atomic, owner-only write. Keep this file
and the durable `tmux-server.initialized` marker with the instance state. A later shell
with different `TMUX` or `TMUX_TMPDIR` does not redirect an established isolated
instance. An invalid or unreadable isolation record is reported rather than overwritten.
If the record disappears while isolation is selected, startup and `destroy --all` stop before
touching sessions. Restore the record from an instance backup, or inspect the
running daemon's `rig terminal status --json` to recover its effective selection.
An ordinary inherited instance may record its socket for a later migration, but
missing, invalid or unwritable records do not prevent its normal startup.
The record's `pinned` flag distinguishes a fixed isolated or migration socket
from an advisory inherited record. Returning to ordinary inheritance commits
`pinned: false` and removes the active marker; an interrupted update must not
leave startup using one server and offline cleanup selecting another. A valid
unpinned record takes precedence over a leftover marker. Legacy inherited records
and the old `1` marker do not make ordinary inheritance depend on that file.
A newly started server loads tmux's normal system and user configuration,
including `~/.tmux.conf`; OpenRig does not substitute an empty configuration.
Existing OpenRig session options, such as `terminal.status_bar`, still apply.
An existing server does not reread its configuration merely because the daemon
restarts. Settings and sessions in a separate personal server are left alone.
## Existing teams and deferred migration
At startup, the daemon checks the previously selected server before switching.
If registered sessions still exist there, it keeps that server and reports a
pending migration. Explicit `OPENRIG_HOME` ownership markers distinguish another
instance's namesakes; unmarked legacy sessions are retained conservatively. If
the old server cannot be inspected, the daemon also keeps it and reports why.
Stop the listed sessions deliberately, then restart the daemon again to apply the
new selection. Merely editing the setting or waiting for the last seat to stop
does not switch a running daemon. The same rule applies when returning to
`inherit` from isolation. A normal inherited start continues to follow its launching
environment, as before. No
live pane is moved, restarted or killed by server selection. Old viewing windows
can be closed separately after their conversations have stopped.
Bring a stopped team back with `rig up <name> --existing`. Its normal restoration
rules still apply: a seat without usable provider history requires an explicit
fresh-start choice before it launches.
On the first upgrade from a version without a socket record, the old-server
candidate comes from the launching environment. If live seats use a non-default
server, start the upgraded daemon from that original server to establish its
record, or stop those seats before changing servers. Earlier versions did not
persist enough information to find an arbitrary old socket from a different shell.
## Attachments, discovery and cleanup
`rig capture`, managed launch/stop/recovery, terminal previews and desktop viewers
use the effective server. `rig ps --nodes --json` includes `tmuxServerArgs` and a
matching `tmuxAttachCommand` for bound tmux seats. `rig tui --shared` uses that
metadata. To attach a terminal directly, use the canonical session name:
```sh
rig terminal attach '<session>'
rig terminal attach --read-only -- '<session>'
```
These commands attach on the daemon's machine. They do not create a seat or
switch somebody else's tmux client. A local CLI pointed at an HTTP remote daemon
cannot attach a same-named local session. For an effectively isolated server,
terminal attachment, shared TUI and desktop windows also compare the daemon's
instance identity with the local durable identity before using local terminal
tools. A loopback URL through a port forward does not bypass this isolation check;
use the matching `OPENRIG_HOME` on the daemon's machine. Inherited servers retain
the existing local-host checks without requiring a matching durable identity.
SSH view tiles follow the viewing instance's effective mode. In `inherit`, they
use remote tmux through `ssh -t`, including `-r` for read-only views, and keep the
bounded, identity-checked reattachment when a seat's session is replaced. The
remote host needs only tmux and a POSIX shell, not OpenRig. Copyable recovery
commands remain plain `ssh -t ... tmux attach`. In `isolated`, tiles run
`rig terminal attach` on the remote host,
using that host's local instance and socket; both the remote CLI and daemon must
support this command. The remote shell must select the right `OPENRIG_HOME` when
using a custom home. Local socket paths are never sent to it, and an isolated
attachment failure does not fall back to a same-named default-server session.
An inherited view does not discover an isolated remote instance's socket; use
the remote instance's `rig terminal attach` directly for that mixed setup.
Discovery and adoption see the selected server. Existing sessions on another
server are not imported automatically. `rig attach --self` reports its current
socket and, for an isolated instance, rejects a different server before binding
or adding a node; an older
CLI without socket metadata must be updated to self-attach to an isolated
instance. A shell outside tmux can still attach as `external_cli`.
`rig destroy --all` reads the recorded effective server before stopping the daemon
or removing its state when isolation or a deferred migration pins it. Ordinary
`inherit` cleanup uses the invoking environment's normal tmux selection, as before;
an old advisory socket is not used to redirect it. Cleanup kills only registered
session names on the selected server.
Dedicated selection requires POSIX tmux. On Windows, psmux retains its existing
server behavior; requesting `isolated` produces a status note and a pending choice.
## Two instances on one machine
Isolation selects only a tmux server. Give each daemon its own OpenRig home,
database and port as well. For example, from a directory where you want to keep
the two instances:
```sh
mkdir -p .openrig-a .openrig-b
env OPENRIG_HOME="$PWD/.openrig-a" OPENRIG_DB="$PWD/.openrig-a/rig.db" \
OPENRIG_PORT=5311 OPENRIG_TMUX_SERVER=isolated rig daemon start
env OPENRIG_HOME="$PWD/.openrig-b" OPENRIG_DB="$PWD/.openrig-b/rig.db" \
OPENRIG_PORT=5312 OPENRIG_TMUX_SERVER=isolated rig daemon start
```
Use the same instance-specific environment for every subsequent command, including
`rig up`, `rig terminal status --json`, attachment and `rig daemon stop`. Do not
reuse a database between these homes. Other inherited `OPENRIG_*` variables still
apply; start from a shell without selectors for another running instance.
The operating-system home, provider credentials and provider conversation history
remain shared unless configured separately. This setting does not isolate the
filesystem or network. Both inherited and isolated servers support the opt-in
`OPENRIG_TMUX_CONTROL=1` execution path; its control client connects to the same
server as the daemon's other tmux commands.
+23
View File
@@ -188,6 +188,29 @@ export class DaemonClient {
private readonly sessionName?: string;
private readonly principal?: DaemonClientOptions["principal"];
/** Isolated terminal tools require this home's durable identity, even on loopback. */
async isLocalInstance(): Promise<boolean> {
if (this.remoteTarget) return false;
const origin = (await import("./local-origin.js")).readLocalOrigin();
return !!origin && await this.matchesInstanceIdentity(origin);
}
private async matchesInstanceIdentity(origin: string): Promise<boolean> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const target = await Promise.race([
(async () => {
const res = await this.fetchImpl(`${this.baseUrl.replace(/\/+$/, "")}/healthz`, { signal: controller.signal });
return res.ok ? (await res.json() as { selfHostId?: unknown }).selfHostId : undefined;
})(),
new Promise<undefined>(resolve => { timer = setTimeout(() => resolve(undefined), Math.min(this.timeoutMs, 1000)); }),
]);
return target === origin;
} catch { return false; }
finally { clearTimeout(timer); controller.abort(); }
}
private async identityHeaders(): Promise<Record<string, string>> {
const { headers, local } = await this.senderHeaders();
// Only beside a seat this client was built for, and only to this host's own daemon: the endpoint itself must answer
+8 -3
View File
@@ -11,7 +11,7 @@ function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).replace(/\r?\n$/, "");
// tmux writes a control character in its output as "_", so a newline between fields can't separate them, and a
// session name may hold spaces or underscores. Ask for the two fixed-shape ids first; the rest of the line is the name.
@@ -20,7 +20,7 @@ const TMUX_CONTEXT_LINE = /^(%\d+) (@\d+) (.+)$/;
export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
| { attachmentType: "external_cli" }
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string } {
| { attachmentType: "tmux"; tmuxSession: string; tmuxWindow?: string; tmuxPane: string; tmuxSocketPath?: string } {
const tmuxPane = process.env["TMUX_PANE"];
if (!tmuxPane) {
return { attachmentType: "external_cli" };
@@ -31,7 +31,11 @@ export function resolveAttachContext(tmuxExec: TmuxExecFn = defaultTmuxExec):
const match = TMUX_CONTEXT_LINE.exec(output.trim());
if (match) {
const [, resolvedPane, tmuxWindow, tmuxSession] = match;
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane! };
// Read the socket separately: tmux replaces control-character delimiters,
// and session names and socket paths may both contain literal spaces.
const tmuxSocketPath = tmuxExec(`tmux display-message -p -t ${shellQuote(resolvedPane!)} '#{socket_path}'`).replace(/\r?\n$/, "");
return { attachmentType: "tmux", tmuxSession: tmuxSession!, tmuxWindow, tmuxPane: resolvedPane!,
...(tmuxSocketPath ? { tmuxSocketPath } : {}) };
}
} catch {
// Fall back to external_cli if tmux metadata cannot be resolved.
@@ -120,6 +124,7 @@ export function attachCommand(depsOverride?: StatusDeps): Command {
body["tmuxSession"] = attachContext.tmuxSession;
if (attachContext.tmuxWindow) body["tmuxWindow"] = attachContext.tmuxWindow;
body["tmuxPane"] = attachContext.tmuxPane;
if (attachContext.tmuxSocketPath) body["tmuxSocketPath"] = attachContext.tmuxSocketPath;
}
if (opts.displayName) body["displayName"] = opts.displayName;
+3 -1
View File
@@ -11,7 +11,7 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
}
// These settings remain boot-only; capture interval/line settings reload live.
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "db.path"];
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "db.path", "terminal.tmux_server"];
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
function isBootOnlyKey(key: string): boolean {
@@ -106,6 +106,7 @@ Keys:
Turn on for custom setups that intentionally rely on tmux globals. Running agents are unchanged.
If the scoped boundary is unavailable, launch continues with its previous environment and a diagnostic.
terminal.status_bar show the inner tmux status bar on launch (default off)
terminal.tmux_server inherit the launching server, or isolated per OpenRig home (restart required)
terminal.history_limit rows of tmux scrollback per pane in the ~/.tmux.conf block rig setup writes (2000-1000000; default 10000).
To keep more: rig config set terminal.history_limit 50000, then rig setup. A running tmux server keeps
its old limit until it restarts or you run tmux source-file ~/.tmux.conf; before tmux 3.7 that changes new panes only
@@ -193,6 +194,7 @@ Precedence: CLI flag > environment variable > config file > default`)
// SWEEP-c: boot-only key + running daemon = stale-until-restart; say so loudly.
if (isBootOnlyKey(key) && (await probeDaemonRunning())) {
console.error(`note: '${key}' is read at daemon BOOT — the running daemon keeps its current value; this takes effect on the next daemon restart (rig daemon stop && rig daemon start).`);
if (key === "terminal.tmux_server") console.error("note: surviving sessions keep their previous server until deliberately stopped; rig terminal status shows the effective selection and any pending migration.");
}
} catch (err) {
console.error((err as Error).message);
+2
View File
@@ -18,6 +18,7 @@ import {
type DestroyScope,
} from "../destroy-helpers.js";
import { stopDaemon } from "../daemon-lifecycle.js";
import { readRecordedTmuxServerArgs } from "../tmux-attachment.js";
export interface DestroyCommandDeps {
configStore: Pick<ConfigStore, "resolve">;
@@ -116,6 +117,7 @@ function realDestroyDeps(): DestroyDeps {
removePath: (targetPath: string) => rmSync(targetPath, { recursive: true, force: true }),
mkdirp: (targetPath: string) => mkdirSync(targetPath, { recursive: true }),
listManagedTmuxSessions: listManagedTmuxSessionsFromDb,
resolveTmuxServerArgs: readRecordedTmuxServerArgs,
killTmuxSession: killTmuxSessionWithCli,
sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
now: () => new Date(),
+33 -2
View File
@@ -1,10 +1,11 @@
// OPR.0.4.6.02 C3 — the `rig terminal` command family (the TUI-1 opaque verb +
// the operator's terminal-provider-ride entry). Three subcommands, all hitting
// the operator's terminal-provider-ride entry). The subcommands read
// the ONE canonical daemon composer (`/api/terminal/...`):
//
// rig terminal open <view> [--provider herdr|cmux|rex] [--json]
// rig terminal views [--json]
// rig terminal status [--provider herdr|cmux|rex] [--json]
// rig terminal attach <session> [--read-only]
//
// `<view>` resolves daemon-side: a rig name (per-rig derived) | `mission:<id>` |
// `slice:<id>` (derived) | a saved-view id. The result is the ONE shared
@@ -22,8 +23,12 @@ import { getDaemonStatus, getDaemonUrl , daemonStatusGuard} from "../daemon-life
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
import { openTerminalWindow, type WindowDeps } from "../terminal-window.js";
import { assertLocalTmux, attachTmux, isLocalTmuxHost, readTmuxServerArgs, type TmuxAttachment } from "../tmux-attachment.js";
export interface TerminalDeps extends StatusDeps { windowDeps?: WindowDeps }
export interface TerminalDeps extends StatusDeps {
windowDeps?: WindowDeps;
attachTmux?: (target: TmuxAttachment) => Promise<number>;
}
export interface FollowControls {
enabled: boolean; maxTiles?: number; graceMs?: number; focusRetentionMs?: number;
pin?: string[]; unpin?: string[]; suppress?: string[]; unsuppress?: string[]; suppressionMs?: number;
@@ -220,5 +225,31 @@ export function terminalCommand(depsOverride?: TerminalDeps): Command {
});
});
cmd.command("attach <session>")
.description("Attach this terminal to a session on the selected daemon's tmux server")
.option("--read-only", "View the session without sending input")
.action(async (session: string, opts: { readOnly?: boolean }) => {
const deps = getDeps();
await withClient(deps, async client => {
try {
if (client.baseUrl && !isLocalTmuxHost(client.baseUrl)) {
throw new Error("Terminal attachment uses local tmux; run this command on the selected daemon's machine.");
}
const response = await client.get<{ tmux?: { mode?: "inherit" | "isolated"; serverArgs?: unknown } }>("/api/terminal/status");
if (response.status >= 400) throw new Error(`Cannot read the tmux server (HTTP ${response.status}).`);
if (response.data.tmux?.serverArgs === undefined) {
throw new Error("This daemon cannot report its tmux server. Update and restart it before using terminal attach.");
}
await assertLocalTmux(client, response.data.tmux.mode);
process.exitCode = await (deps.attachTmux ?? attachTmux)({
session, serverArgs: readTmuxServerArgs(response.data.tmux.serverArgs), readOnly: opts.readOnly ?? false,
});
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
});
});
return cmd;
}
+3 -2
View File
@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { openMissionControl, USAGE_LINES, type FrontDoorIo } from "../front-door.js";
import { sharedTuiTarget, attachSharedTui } from "../shared-tui.js";
import type { TmuxAttachment } from "../tmux-attachment.js";
/** The registry-entry shape `rig tui commands` serializes (REGISTRY I2, ruling 64f1dbdf).
* Structural mirror of the TUI's CommandEntry data fields (functions are not serialized). */
@@ -38,8 +39,8 @@ async function loadRegistryFromDist(baseDir: string): Promise<TuiCommandEntry[]>
export function tuiCommand(io: FrontDoorIo & {
loadRegistry?: () => Promise<TuiCommandEntry[]>;
sharedTarget?: () => Promise<string>;
attachShared?: (target: string) => Promise<number>;
sharedTarget?: () => Promise<TmuxAttachment>;
attachShared?: (target: TmuxAttachment) => Promise<number>;
} = {}): Command {
const cmd = new Command("tui")
.description("open the status dashboard, not agent chat (--shared is the OpenRig view fallback)")
+12
View File
@@ -165,6 +165,8 @@ export interface RiggedConfig {
// `rig config get/set terminal.status_bar` surface + twin parity.
terminal: {
statusBar: boolean;
/** Applied at daemon startup; a live previous server is retained until drained. */
tmuxServer: "inherit" | "isolated";
/** Rows of tmux scrollback per pane; `rig setup` writes it into the managed `~/.tmux.conf` block. */
historyLimit: number;
inheritTmuxEnvironment: boolean;
@@ -355,6 +357,7 @@ const DEFAULTS = {
// OPR.0.4.6.02 S1 — inner-tmux status bar OFF at launch by default.
terminal: {
statusBar: false,
tmuxServer: "inherit",
historyLimit: 10000,
inheritTmuxEnvironment: false,
},
@@ -504,6 +507,7 @@ export const VALID_KEYS = [
// boolean (default off), lockstep with the daemon settings-store twin
// (the parity test pins both). Flip is future-launches-only (BR-1).
"terminal.status_bar",
"terminal.tmux_server",
// The managed ~/.tmux.conf block's history-limit, written by `rig setup`; lockstep with the daemon twin.
"terminal.history_limit",
"terminal.inherit_tmux_environment",
@@ -626,6 +630,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
"snapshots.periodic.retention_keep": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_RETENTION_KEEP" },
// OPR.0.4.6.02 S1 — net-new key; OPENRIG_* primary only (no RIGGED_* legacy).
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
"terminal.tmux_server": { primary: "OPENRIG_TMUX_SERVER" },
"terminal.history_limit": { primary: "OPENRIG_TERMINAL_HISTORY_LIMIT" },
"terminal.inherit_tmux_environment": { primary: "OPENRIG_TERMINAL_INHERIT_TMUX_ENVIRONMENT" },
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
@@ -724,6 +729,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
"snapshots.periodic.interval_seconds": ["snapshots", "periodic", "intervalSeconds"],
"snapshots.periodic.retention_keep": ["snapshots", "periodic", "retentionKeep"],
"terminal.status_bar": ["terminal", "statusBar"],
"terminal.tmux_server": ["terminal", "tmuxServer"],
"terminal.history_limit": ["terminal", "historyLimit"],
"terminal.inherit_tmux_environment": ["terminal", "inheritTmuxEnvironment"],
"output.compact": ["output", "compact"],
@@ -950,6 +956,11 @@ function percentageConstraint(key: string) {
}
const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
"terminal.tmux_server": (raw) => {
if (raw !== "inherit" && raw !== "isolated") {
throw new Error(`Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`);
}
},
"daemon.db_conflict": (raw) => {
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
},
@@ -1329,6 +1340,7 @@ export class ConfigStore {
},
terminal: {
statusBar: v("terminal.status_bar") as boolean,
tmuxServer: v("terminal.tmux_server") as "inherit" | "isolated",
historyLimit: v("terminal.history_limit") as number,
inheritTmuxEnvironment: v("terminal.inherit_tmux_environment") as boolean,
},
+8 -4
View File
@@ -59,7 +59,8 @@ export interface DestroyDeps {
removePath: (path: string) => void;
mkdirp: (path: string) => void;
listManagedTmuxSessions: (dbPath: string) => string[];
killTmuxSession: (sessionName: string) => boolean;
resolveTmuxServerArgs?: (stateRoot: string) => string[];
killTmuxSession: (sessionName: string, serverArgs?: readonly string[]) => boolean;
sleep: (ms: number) => Promise<void>;
now: () => Date;
}
@@ -179,6 +180,9 @@ function isLocalHost(host: string): boolean {
}
export async function executeDestroy(plan: DestroyPlan, deps: DestroyDeps): Promise<DestroyResult> {
// Resolve before stopping the daemon or moving its state. An unreadable record
// must not turn a managed-session cleanup into a kill on the caller's server.
const serverArgs = plan.scope === "all" ? deps.resolveTmuxServerArgs?.(plan.stateRoot) : undefined;
const warnings = [...plan.warnings];
const backupPaths: string[] = [];
let stateRecreated = false;
@@ -239,7 +243,7 @@ export async function executeDestroy(plan: DestroyPlan, deps: DestroyDeps): Prom
let tmuxMissing = 0;
if (plan.scope === "all") {
for (const sessionName of plan.managedTmuxSessions) {
if (deps.killTmuxSession(sessionName)) tmuxKilled += 1;
if (deps.killTmuxSession(sessionName, serverArgs)) tmuxKilled += 1;
else tmuxMissing += 1;
}
}
@@ -284,9 +288,9 @@ export function findListeningPidWithLsof(port: number): number | null {
}
}
export function killTmuxSessionWithCli(sessionName: string): boolean {
export function killTmuxSessionWithCli(sessionName: string, serverArgs: readonly string[] = []): boolean {
try {
execFileSync("tmux", ["kill-session", "-t", sessionName], { stdio: "ignore" });
execFileSync("tmux", [...serverArgs, "kill-session", "-t", `=${sessionName}`], { stdio: "ignore" });
return true;
} catch {
return false;
+9 -13
View File
@@ -1,13 +1,14 @@
import { spawn } from "node:child_process";
import { DaemonClient } from "./client.js";
import { assertLocalTmux, attachTmux, readTmuxServerArgs, type TmuxAttachment } from "./tmux-attachment.js";
interface SharedTuiClient {
readonly baseUrl?: string;
isLocalInstance(): Promise<boolean>;
get<T>(path: string): Promise<{ status: number; data: T }>;
}
/** Resolve the terminal on the selected instance, not a remembered tmux name. */
export async function sharedTuiTarget(client: SharedTuiClient = new DaemonClient()): Promise<string> {
export async function sharedTuiTarget(client: SharedTuiClient = new DaemonClient()): Promise<TmuxAttachment> {
if (client.baseUrl && !["localhost", "127.0.0.1", "[::1]"].includes(new URL(client.baseUrl).hostname)) {
throw new Error("Shared attachment uses local tmux. Run rig tui --shared on the selected daemon's machine; standalone rig tui can use this remote connection.");
}
@@ -20,23 +21,18 @@ export async function sharedTuiTarget(client: SharedTuiClient = new DaemonClient
const nodes = await client.get<Array<{
logicalId: string; runtime: string | null;
canonicalSessionName: string | null; tmuxAttachCommand: string | null;
tmuxServerArgs?: string[];
tmuxServerMode?: "inherit" | "isolated";
}>>(`/api/rigs/${encodeURIComponent(kernel.rigId)}/nodes`);
if (nodes.status >= 400) throw new Error(`Cannot read kernel terminals (HTTP ${nodes.status}); run rig status.`);
const terminal = nodes.data.find((n) => n.logicalId === "operator.human" && n.runtime === "terminal");
if (!terminal?.canonicalSessionName || !terminal.tmuxAttachCommand) {
throw new Error("The kernel has no bound shared terminal. Inspect rig ps --nodes --rig kernel; restore the existing terminal rather than launching another kernel. Standalone: rig tui.");
}
return terminal.canonicalSessionName;
await assertLocalTmux(client, terminal.tmuxServerMode, "Shared attachment uses local tmux. The selected daemon's instance identity could not be confirmed locally; run rig tui --shared on that daemon's machine. Standalone rig tui can use this remote connection.");
return { session: terminal.canonicalSessionName, serverArgs: readTmuxServerArgs(terminal.tmuxServerArgs) };
}
export async function attachSharedTui(target: string): Promise<number> {
// Attach a new client even inside tmux. switch-client would retarget somebody
// else's client when invoked from an agent's child shell.
return new Promise((resolve, reject) => {
const env = { ...process.env };
delete env.TMUX;
const child = spawn("tmux", ["attach-session", "-t", `=${target}`], { stdio: "inherit", env });
child.once("error", reject);
child.once("exit", (code) => resolve(code ?? 1));
});
export async function attachSharedTui(target: TmuxAttachment): Promise<number> {
return attachTmux(target);
}
+36 -31
View File
@@ -1,12 +1,13 @@
import { execFile, spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir, hostname, networkInterfaces } from "node:os";
import { homedir } from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { promisify } from "node:util";
import { terminalAuthHeaders, type DaemonClient } from "./client.js";
import { shellQuote } from "./cross-host-executor.js";
import { prepareHerdrLaunchConfig } from "./herdr-launch-config.js";
import { assertLocalTmux, isLocalTmuxHost, readTmuxServerArgs } from "./tmux-attachment.js";
import { openRexView } from "./terminal-window-rex.js";
import type { FollowControls, OpenViewResult } from "./commands/terminal.js";
@@ -15,8 +16,9 @@ interface HerdrTab { workspace_id: string; tab_id: string; label: string }
interface Preview {
planId: string;
session?: string;
tmux?: { mode: "inherit" | "isolated" };
status: { launch?: { socketPath: string; session?: string } };
composed: { id: string; opened: Pane[]; pages: Pane[][]; columns?: number; kernelLayout?: string; spaceLabel?: string; absent: OpenViewResult["absent"]; degraded: OpenViewResult["degraded"] };
composed: { id: string; localTmuxServer?: readonly string[]; opened: Pane[]; pages: Pane[][]; columns?: number; kernelLayout?: string; spaceLabel?: string; absent: OpenViewResult["absent"]; degraded: OpenViewResult["degraded"] };
}
export interface WindowDeps {
@@ -82,13 +84,6 @@ export function defaultWindowDeps(): WindowDeps {
};
}
function localDaemon(url: string): boolean {
const host = new URL(url).hostname.replace(/^\[|\]$/g, "");
return ["localhost", "::1", "::", "0.0.0.0", hostname()].includes(host)
|| /^127\./.test(host)
|| Object.values(networkInterfaces()).flat().some(iface => iface?.address === host);
}
/** A tmux started inside a Herdr pane sets TERM_PROGRAM=tmux but inherits Herdr's pane markers. */
function inHerdrPane(env: NodeJS.ProcessEnv): boolean {
return env["HERDR_ENV"] === "1" && !!env["HERDR_PANE_ID"] && !!env["HERDR_SOCKET_PATH"];
@@ -210,7 +205,7 @@ function failure(provider: string, error: string): OpenViewResult {
}
/** Saved Herdr labels survive reboot; only current clients prove a local attachment. */
async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: Pane[][], pageLabel: (index: number) => string, herdrArgs: string[], deps: WindowDeps): Promise<boolean> {
async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: Pane[][], pageLabel: (index: number) => string, herdrArgs: string[], deps: WindowDeps, serverArgs: readonly string[]): Promise<boolean> {
try {
const tmux = (await deps.exec("/bin/sh", ["-c", "command -v tmux"])).trim();
if (!path.isAbsolute(tmux)) return false;
@@ -220,14 +215,15 @@ async function hasLiveAttachments(existing: HerdrTab, tabs: HerdrTab[], pages: P
const clients = new Map<string, string[]>();
// Match view-composer's serialization (these strings are compared, never executed).
const quote = (value: string) => "'" + value.replace(/'/g, "'\"'\"'") + "'";
const inventory = await deps.exec(tmux, ["list-clients", "-F", "#{client_pid}\t#{session_name}\t#{client_readonly}"]);
const server = serverArgs.map((arg, index) => index % 2 ? quote(arg) : arg).join(" ");
const inventory = await deps.exec(tmux, [...serverArgs, "list-clients", "-F", "#{client_pid}\t#{session_name}\t#{client_readonly}"]);
for (const line of inventory.trim().split("\n")) {
const [pid, session, readOnly] = line.split("\t");
if (!pid || !/^\d+$/.test(pid) || !session || !["0", "1"].includes(readOnly ?? "")) continue;
// Compare the composer's exact command, including session aliases and read-only mode.
// SSH/custom commands cannot be confirmed from this local client inventory.
const flags = readOnly === "1" ? ["-r "] : ["", "-f ignore-size "];
clients.set(pid, ["tmux", quote(tmux)].flatMap(binary => flags.map(flag => `${binary} attach ${flag}-t ${quote(session)}`)));
clients.set(pid, ["tmux", quote(tmux)].flatMap(binary => flags.map(flag => `${binary}${server ? ` ${server}` : ""} attach ${flag}-t ${quote(session)}`)));
}
const used = new Set<string>();
for (const [index, page] of pages.entries()) {
@@ -282,7 +278,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
: `No terminal window was opened. ${reason} On the daemon's desktop, run: ${recovery}`), windowAttempted, ...(session !== undefined ? { session } : {}) });
const windowNotes: string[] = [];
try {
if (!localDaemon(client.baseUrl)) throw new Error("The window launcher must run on the daemon's own desktop; the configured daemon is remote.");
if (!isLocalTmuxHost(client.baseUrl)) throw new Error("The window launcher must run on the daemon's own desktop; the configured daemon is remote.");
if (following && selectedProvider !== "herdr") throw new Error("Following requires the qualified Herdr provider.");
if (session !== undefined && selectedProvider !== "herdr") throw new Error("--session selects a Herdr session; use --provider herdr.");
if (selectedProvider && !["herdr", "tmux"].includes(selectedProvider)) throw new Error("--window supports herdr, rex or tmux. Use cmux without --window.");
@@ -305,6 +301,8 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
}
const launchTarget = preview.data.status.launch;
let { composed, planId } = preview.data;
await assertLocalTmux(client, preview.data.tmux?.mode, "The window launcher must run on the daemon's own desktop; its instance identity could not be confirmed locally.");
const tmuxServer = readTmuxServerArgs(composed.localTmuxServer);
if (expectedPlan !== undefined && expectedPlan !== planId) {
return failure(provider, "The terminal view changed since preview. Refresh the preview before opening.");
}
@@ -314,10 +312,14 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
viewportColumns = window.columns;
const next = await client.get<Preview | OpenViewResult>(previewUrl());
if (next.status >= 400 || !("composed" in next.data)) throw new Error("Could not compose the measured terminal view.");
await assertLocalTmux(client, next.data.tmux?.mode, "The window launcher must run on the daemon's own desktop; its instance identity could not be confirmed locally.");
if (session !== undefined && (next.data.session !== session || next.data.status.launch?.socketPath !== launchTarget?.socketPath)) {
throw new Error("The selected Herdr endpoint changed while opening the window. Inspect it before retrying.");
}
if (expectedPlan !== undefined && expectedPlan !== next.data.planId) throw new Error("The measured terminal layout differs from the expected preview; refresh the preview before opening.");
if (JSON.stringify(readTmuxServerArgs(next.data.composed.localTmuxServer)) !== JSON.stringify(tmuxServer)) {
throw new Error("The daemon's tmux server changed while opening the window; refresh the preview before retrying.");
}
({ composed, planId } = next.data);
if (!composed.opened.length) throw new Error("No conversations are attachable after opening the terminal.");
}
@@ -360,9 +362,9 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
}
const directCommand = herdr && socket
? `${configPrefix}env -u TMUX -u HERDR_SESSION -u HERDR_SOCKET_PATH HERDR_SOCKET_PATH=${shellQuote(socket)}${configEnv} ${shellQuote(herdr)}`
: `env -u TMUX ${composed.opened[0]!.paneCommand.replace(/^ssh /, "ssh -t ")}`;
: `env -u TMUX ${composed.opened[0]!.paneCommand.replace(/^ssh (?!-t(?:\s|$))/, "ssh -t ")}`;
manualCommand = herdr && socket ? directCommand
: `env -u TMUX ${(composed.opened[0]!.recoveryCommand ?? composed.opened[0]!.paneCommand).replace(/^ssh /, "ssh -t ")}`;
: `env -u TMUX ${(composed.opened[0]!.recoveryCommand ?? composed.opened[0]!.paneCommand).replace(/^ssh (?!-t(?:\s|$))/, "ssh -t ")}`;
if (typeof launchWindow === "string") {
const headless = deps.env["SSH_CONNECTION"] || deps.env["SSH_CLIENT"] || deps.env["SSH_TTY"]
|| (deps.platform === "linux" && !deps.env["DISPLAY"] && !deps.env["WAYLAND_DISPLAY"]);
@@ -467,7 +469,7 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
}
let existing: HerdrTab | undefined;
for (const candidate of candidates) {
if (await hasLiveAttachments(candidate, tabs, composed.pages, pageLabel(candidate), herdrArgs, deps)) { existing = candidate; break; }
if (await hasLiveAttachments(candidate, tabs, composed.pages, pageLabel(candidate), herdrArgs, deps, tmuxServer)) { existing = candidate; break; }
windowNotes.push(`Could not confirm live attachments in workspace ${candidate.workspace_id}. Its contents were kept. After inspecting it, close it if no longer needed: ${closeCommand(candidate.workspace_id)}`);
}
if (existing) {
@@ -494,14 +496,17 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
if (typeof launchWindow !== "function") throw new Error("No terminal window launcher is available.");
const tmux = (await deps.exec("/bin/sh", ["-c", "command -v tmux"]).catch(() => "")).trim();
if (!tmux) throw new Error("tmux is unavailable; run rig setup first.");
const runTmux = (args: string[]) => deps.exec(tmux, [...tmuxServer, ...args]);
const serverWords = tmuxServer.map((arg, index) => index % 2 ? shellQuote(arg) : arg).join(" ");
const tmuxCommand = `${shellQuote(tmux)}${serverWords ? ` ${serverWords}` : ""}`;
viewer = `openrig-view-${deps.id()}`;
let placeholder: string | undefined;
if (composed.kernelLayout) {
// This is our fresh, empty viewer, never a source seat. Attach it first so Terminal can
// report the real new window width before we select the OpenRig view layout.
placeholder = (await deps.exec(tmux, ["new-session", "-d", "-s", viewer, "-n", "view-1", "-P", "-F", "#{pane_id}"])).trim();
placeholder = (await runTmux(["new-session", "-d", "-s", viewer, "-n", "view-1", "-P", "-F", "#{pane_id}"])).trim();
windowAttempted = true;
window = await launchWindow(`env -u TMUX ${shellQuote(tmux)} attach-session -t ${shellQuote(`=${viewer}`)}`);
window = await launchWindow(`env -u TMUX ${tmuxCommand} attach-session -t ${shellQuote(`=${viewer}`)}`);
await measureWindow();
}
for (const [pageIndex, page] of composed.pages.entries()) {
@@ -511,27 +516,27 @@ export async function openTerminalWindow(client: DaemonClient, view: string, req
const args = pageIndex === 0 ? ["new-session", "-d", "-s", viewer, "-n", name] : ["new-window", "-d", "-t", `${viewer}:`, "-n", name];
let pane: string;
if (pageIndex === 0 && placeholder) {
await deps.exec(tmux, ["respawn-pane", "-k", "-t", placeholder, `env -u TMUX ${first.paneCommand}`]);
await runTmux(["respawn-pane", "-k", "-t", placeholder, `env -u TMUX ${first.paneCommand}`]);
pane = placeholder;
} else pane = (await deps.exec(tmux, [...args, "-P", "-F", "#{pane_id}", `env -u TMUX ${first.paneCommand}`])).trim();
} else pane = (await runTmux([...args, "-P", "-F", "#{pane_id}", `env -u TMUX ${first.paneCommand}`])).trim();
// Nested source clients on this server can make a new window too narrow to split.
// Use the viewer's configured detached size, then restore normal client resizing.
const [width, height] = (await deps.exec(tmux, ["show-options", "-Av", "-t", viewer, "default-size"])).trim().split("x");
const sizing = (await deps.exec(tmux, ["show-options", "-Awv", "-t", pane, "window-size"])).trim();
await deps.exec(tmux, ["resize-window", "-t", pane, "-x", width!, "-y", height!]);
await deps.exec(tmux, ["select-pane", "-t", pane, "-T", first.label]);
const [width, height] = (await runTmux(["show-options", "-Av", "-t", viewer, "default-size"])).trim().split("x");
const sizing = (await runTmux(["show-options", "-Awv", "-t", pane, "window-size"])).trim();
await runTmux(["resize-window", "-t", pane, "-x", width!, "-y", height!]);
await runTmux(["select-pane", "-t", pane, "-T", first.label]);
for (const item of page.slice(1)) {
pane = (await deps.exec(tmux, ["split-window", "-d", "-h", "-t", pane, "-P", "-F", "#{pane_id}", `env -u TMUX ${item.paneCommand}`])).trim();
await deps.exec(tmux, ["select-pane", "-t", pane, "-T", item.label]);
await deps.exec(tmux, ["select-layout", "-t", `${viewer}:${name}`, "even-horizontal"]);
pane = (await runTmux(["split-window", "-d", "-h", "-t", pane, "-P", "-F", "#{pane_id}", `env -u TMUX ${item.paneCommand}`])).trim();
await runTmux(["select-pane", "-t", pane, "-T", item.label]);
await runTmux(["select-layout", "-t", `${viewer}:${name}`, "even-horizontal"]);
}
await deps.exec(tmux, ["select-layout", "-t", `${viewer}:${name}`, composed.columns === page.length ? "even-horizontal" : "tiled"]);
await deps.exec(tmux, ["set-option", "-w", "-t", `${viewer}:${name}`, "window-size", sizing]);
await runTmux(["select-layout", "-t", `${viewer}:${name}`, composed.columns === page.length ? "even-horizontal" : "tiled"]);
await runTmux(["set-option", "-w", "-t", `${viewer}:${name}`, "window-size", sizing]);
}
await deps.exec(tmux, ["select-window", "-t", `${viewer}:view-1`]);
await runTmux(["select-window", "-t", `${viewer}:view-1`]);
if (!window) {
windowAttempted = true;
window = await launchWindow(`env -u TMUX ${shellQuote(tmux)} attach-session -t ${shellQuote(`=${viewer}`)}`);
window = await launchWindow(`env -u TMUX ${tmuxCommand} attach-session -t ${shellQuote(`=${viewer}`)}`);
}
return { provider, ok: true, opened: composed.opened.map(pane => pane.seat), absent: composed.absent, degraded: composed.degraded, pages: composed.pages.length, window, notes: [`Viewing session: ${viewer}. Existing conversations were preserved.`, ...windowNotes, "Check the new terminal shows the intended view; window creation alone is not visual confirmation."] };
} catch (err) {
+94
View File
@@ -0,0 +1,94 @@
import { spawn } from "node:child_process";
import { readFileSync, realpathSync } from "node:fs";
import { hostname, networkInterfaces } from "node:os";
import path from "node:path";
import type { DaemonClient } from "./client.js";
export interface TmuxAttachment {
session: string;
serverArgs: string[];
readOnly?: boolean;
}
/** The daemon supplies server options as data; clients never parse a shell command. */
export function readTmuxServerArgs(value: unknown): string[] {
if (value === undefined) return []; // Compatibility with a daemon predating server metadata.
if (!Array.isArray(value) || (value.length !== 0 && (value.length !== 2
|| (value[0] !== "-S" && value[0] !== "-L")
|| typeof value[1] !== "string" || !value[1] || value[1].includes("\0")))) {
throw new Error("The daemon returned invalid tmux server options.");
}
return [...value] as string[];
}
/** Offline cleanup pins only active isolation/migration; ordinary inherit uses tmux's environment. */
export function readRecordedTmuxServerArgs(home: string): string[] {
let record: { version?: unknown; home?: unknown; mode?: unknown; socketPath?: unknown; isolatedSocketPath?: unknown; pinned?: unknown } | null = null;
let readError: unknown;
try { record = JSON.parse(readFileSync(path.join(home, "tmux-server.json"), "utf8")); }
catch (error) { readError = error; }
const pinned = record?.mode === "isolated" || record?.pinned === true;
let matchesHome = false;
try { matchesHome = record?.home === realpathSync(home); } catch { /* A missing/unreadable home is not a valid record. */ }
const valid = record?.version === 1 && matchesHome
&& (record.mode === "inherit" || record.mode === "isolated")
&& (record.pinned === undefined || typeof record.pinned === "boolean")
&& !(record.mode === "isolated" && record.pinned === false)
&& typeof record.socketPath === "string" && path.isAbsolute(record.socketPath) && !record.socketPath.includes("\0")
&& (record.isolatedSocketPath === undefined || (typeof record.isolatedSocketPath === "string"
&& path.isAbsolute(record.isolatedSocketPath) && !record.isolatedSocketPath.includes("\0")));
if (valid) {
// An explicit opt-out record is committed before the marker is removed.
// Older inherit records are advisory too, never a remembered cleanup socket.
return pinned ? readTmuxServerArgs(["-S", record!.socketPath]) : [];
}
let marked = false;
if (!pinned) {
try { marked = readFileSync(path.join(home, "tmux-server.initialized"), "utf8").trim() === "openrig-tmux-pinned-v1"; }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw new Error(`Cannot read the tmux server pin marker: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (!marked) return []; // No active pin: missing/bad advisory records cannot change inheritance.
}
if (readError) {
if ((readError as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error("Missing tmux server record after pinned server selection; restore it before destroying this instance's sessions.");
}
throw new Error(`Cannot read the tmux server record: ${readError instanceof Error ? readError.message : String(readError)}`);
}
throw new Error("Invalid tmux server record; inspect it before destroying this instance's sessions.");
}
/** Host locality keeps the existing window-launcher behaviour in inherit mode. */
export function isLocalTmuxHost(url: string): boolean {
const host = new URL(url).hostname.replace(/^\[|\]$/g, "");
return ["localhost", "::1", "::", "0.0.0.0", hostname()].includes(host)
|| /^127\./.test(host)
|| Object.values(networkInterfaces()).flat().some(iface => iface?.address === host);
}
/** Only an effective isolated server needs proof beyond the legacy host check. */
export async function assertLocalTmux(
client: Pick<DaemonClient, "isLocalInstance">,
mode: "inherit" | "isolated" | undefined,
message = "Terminal attachment uses local tmux. The selected daemon's instance identity could not be confirmed locally; run this command on that daemon's machine.",
): Promise<void> {
if (mode === "isolated" && !await client.isLocalInstance()) throw new Error(message);
}
/** Attach only; creating or retargeting somebody else's tmux client is not part of this operation. */
export function attachTmux(target: TmuxAttachment): Promise<number> {
const server = readTmuxServerArgs(target.serverArgs);
return new Promise((resolve, reject) => {
const env = { ...process.env };
delete env.TMUX;
delete env.TMUX_PANE;
const child = spawn("tmux", [...server, "attach-session", ...(target.readOnly ? ["-r"] : []), "-t", `=${target.session}`],
{ stdio: "inherit", env, windowsHide: true });
child.once("error", reject);
child.once("exit", code => resolve(code ?? 1));
});
}
+10 -3
View File
@@ -181,6 +181,13 @@ describe("Attach CLI", () => {
});
});
it("includes the actual socket without trimming literal path whitespace", () => {
const result = withTmuxPane("%42", () => resolveAttachContext(fakeTmux({
pane: "%42", window: "@1", session: "team_1 rig_b @7 %9", socket: "/private/socket name ",
})));
expect(result).toMatchObject({ attachmentType: "tmux", tmuxSocketPath: "/private/socket name ", tmuxSession: "team_1 rig_b @7 %9" });
});
// Real tmux rewrote the newlines of a combined format as "_" ("unrelated-shell_@2_%2"), and the session fell back to
// external_cli, so replies to the self-attached member had no transport.
it.each([
@@ -216,18 +223,18 @@ describe.runIf(tmuxAvailable)("resolveAttachContext against a real tmux server (
const window = read("#{window_id}");
const onSocket = (cmd: string) => execSync(cmd.replace(/^tmux /, `tmux -L ${TMUX_SOCKET} `), { encoding: "utf8" }).trim();
expect(withTmuxPane(pane, () => resolveAttachContext(onSocket)))
.toEqual({ attachmentType: "tmux", tmuxSession: session, tmuxWindow: window, tmuxPane: pane });
.toEqual({ attachmentType: "tmux", tmuxSession: session, tmuxWindow: window, tmuxPane: pane, tmuxSocketPath: read("#{socket_path}") });
});
});
/** A `tmux display-message -p` double that renders the requested format as real tmux does, control characters
* included: tmux writes each one as "_". */
function fakeTmux(values: { session: string; window: string; pane: string }) {
function fakeTmux(values: { session: string; window: string; pane: string; socket?: string }) {
return vi.fn((cmd: string) => {
const format = /'([^']*)'\s*$/.exec(cmd)?.[1] ?? /"([^"]*)"\s*$/.exec(cmd)?.[1];
if (format === undefined) throw new Error(`unexpected tmux command: ${cmd}`);
return format.replace(/#\{session_name\}/g, values.session).replace(/#\{window_id\}/g, values.window)
.replace(/#\{pane_id\}/g, values.pane).replace(/[\u0000-\u001f]/g, "_");
.replace(/#\{pane_id\}/g, values.pane).replace(/#\{socket_path\}/g, values.socket ?? "").replace(/[\u0000-\u001f]/g, "_");
});
}
@@ -44,7 +44,7 @@ function clearEnv(): () => void {
// its custom home into default-path resolution; UNSETTING it would fall through
// to the REAL ~/.openrig (foreign on-box state) — so tests set a fresh tmp home.
"OPENRIG_HOME", "RIGGED_HOME",
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_DAEMON_DB_CONFLICT",
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_DAEMON_DB_CONFLICT",
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
"OPENRIG_TRANSCRIPTS_LINES", "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
@@ -109,6 +109,16 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
restoreEnv();
});
it("resolves tmux isolation from the shared config and environment with a strict mode", () => {
const store = new ConfigStore(configPath);
expect(store.resolve().terminal.tmuxServer).toBe("inherit");
store.set("terminal.tmux_server", "isolated");
expect(store.resolve().terminal.tmuxServer).toBe("isolated");
process.env.OPENRIG_TMUX_SERVER = "inherit";
expect(store.resolve().terminal.tmuxServer).toBe("inherit");
expect(() => store.set("terminal.tmux_server", "isolate")).toThrow('must be "inherit" or "isolated"');
});
it("VALID_KEYS includes legacy, user-settings, ui.preview, recovery, agents, feed, and transcript-rotation keys", () => {
const expected = [
"daemon.port", "daemon.host", "daemon.db_conflict",
@@ -174,6 +184,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"snapshots.periodic.retention_keep",
// OPR.0.4.6.02 S1 — inner-tmux status-bar launch default (static bool).
"terminal.status_bar",
"terminal.tmux_server",
"terminal.history_limit",
"terminal.inherit_tmux_environment",
// Slice 15 (OPR.0.7.0.15) — compact command-output defaults switch.
+41 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Command } from "commander";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, renameSync, rmSync } from "node:fs";
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, realpathSync, renameSync, rmSync } from "node:fs";
import { basename, join } from "node:path";
import { tmpdir } from "node:os";
import Database from "better-sqlite3";
@@ -13,6 +13,7 @@ import {
type DestroyDeps,
} from "../src/destroy-helpers.js";
import { destroyCommand } from "../src/commands/destroy.js";
import { readRecordedTmuxServerArgs } from "../src/tmux-attachment.js";
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
return new Promise(async (resolve) => {
@@ -64,6 +65,45 @@ describe("destroy helpers", () => {
rmSync(tmpDir, { recursive: true, force: true });
});
it("resolves the effective socket before stopping or deleting state and passes it to every session kill", async () => {
const args = ["-S", join(tmpDir, "selected.sock")];
writeFileSync(join(tmpDir, "tmux-server.json"), JSON.stringify({ version: 1, home: realpathSync(tmpDir),
mode: "isolated", pinned: true, socketPath: args[1] }));
const deps = makeDestroyDeps({ resolveTmuxServerArgs: vi.fn(readRecordedTmuxServerArgs) });
const plan = buildDestroyPlan("all", false, { stateRoot: tmpDir, dbPath: join(tmpDir, "db"),
transcriptsPath: join(tmpDir, "transcripts"), daemonHost: "localhost", daemonPort: 7433 }, deps);
plan.managedTmuxSessions = ["seat"];
await executeDestroy(plan, deps);
expect(deps.resolveTmuxServerArgs).toHaveBeenCalledWith(tmpDir);
expect(deps.killTmuxSession).toHaveBeenCalledWith("seat", args);
expect(vi.mocked(deps.resolveTmuxServerArgs!).mock.invocationCallOrder[0])
.toBeLessThan(vi.mocked(deps.stopDaemon).mock.invocationCallOrder[0]!);
});
it("leaves daemon, sessions and state intact if its pinned server record is missing", async () => {
writeFileSync(join(tmpDir, "tmux-server.initialized"), "openrig-tmux-pinned-v1\n");
const deps = makeDestroyDeps({ resolveTmuxServerArgs: readRecordedTmuxServerArgs });
const plan = buildDestroyPlan("all", false, { stateRoot: tmpDir, dbPath: join(tmpDir, "db"),
transcriptsPath: join(tmpDir, "transcripts"), daemonHost: "localhost", daemonPort: 7433 }, deps);
await expect(executeDestroy(plan, deps)).rejects.toThrow("Missing tmux server record");
expect(deps.stopDaemon).not.toHaveBeenCalled();
expect(deps.killTmuxSession).not.toHaveBeenCalled();
expect(deps.removePath).not.toHaveBeenCalled();
});
it.each(["missing", "malformed", "stale"])("keeps inherited cleanup on tmux's current server with a %s advisory record", async state => {
if (state === "malformed") writeFileSync(join(tmpDir, "tmux-server.json"), "{");
if (state === "stale") writeFileSync(join(tmpDir, "tmux-server.json"), JSON.stringify({ version: 1,
home: realpathSync(tmpDir), mode: "inherit", pinned: false, socketPath: join(tmpDir, "old.sock") }));
const deps = makeDestroyDeps({ resolveTmuxServerArgs: readRecordedTmuxServerArgs });
const plan = buildDestroyPlan("all", false, { stateRoot: tmpDir, dbPath: join(tmpDir, "db"),
transcriptsPath: join(tmpDir, "transcripts"), daemonHost: "localhost", daemonPort: 7433 }, deps);
plan.managedTmuxSessions = ["seat"];
await executeDestroy(plan, deps);
expect(deps.stopDaemon).toHaveBeenCalled();
expect(deps.killTmuxSession).toHaveBeenCalledExactlyOnceWith("seat", []);
});
it("buildBackupPath appends timestamp and resolves collisions", () => {
const target = join(tmpDir, ".openrig");
const first = buildBackupPath(target, existsSync, new Date("2026-04-10T08:10:11Z"));
+42
View File
@@ -87,3 +87,45 @@ describe("direct endpoint attribution", () => {
expect(requests).toHaveLength(1); expect(requests[0]?.headers["X-OpenRig-Session"]).toBe("rig-admin@ops@vm-origin");
});
});
describe("local instance proof for terminal attachment", () => {
it.each([
["local-instance", true], ["remote-instance", false], [undefined, false],
] as const)("compares the loopback endpoint's identity %s with this home's durable identity", async (target, expected) => {
seed("local-instance");
// Attachment also requires proof outside an agent seat and without OPENRIG_URL.
vi.stubEnv("OPENRIG_SESSION_NAME", ""); vi.stubEnv("RIGGED_SESSION_NAME", ""); vi.stubEnv("OPENRIG_URL", "");
const { factory, requests } = client(target);
expect(await factory("http://127.0.0.1:12345").isLocalInstance()).toBe(expected);
expect(requests.map(r => r.url)).toEqual(["http://127.0.0.1:12345/healthz"]);
});
it("does not accept the endpoint's identity when the local store is absent", async () => {
const { factory, requests } = client("remote-instance");
expect(await factory("http://localhost:7433").isLocalInstance()).toBe(false);
expect(requests).toEqual([]);
expect(existsSync(dbPath)).toBe(false);
});
it("does not use a caller-supplied origin or a registered remote as local proof", async () => {
seed("local-instance");
const { factory } = client("remote-instance");
const c = factory("http://localhost:7433");
c.originSelfHostId = "remote-instance";
expect(await c.isLocalInstance()).toBe(false);
const same = client("local-instance");
expect(await remoteDaemonClient(same.factory, "http://localhost:7433").isLocalInstance()).toBe(false);
expect(same.requests).toEqual([]);
});
it("bounds an unresponsive identity probe and cancels it", async () => {
seed("local-instance");
let signal: AbortSignal | null | undefined;
const c = new DaemonClient("http://127.0.0.1:12345", { timeoutMs: 25, fetchImpl: (async (_url, init) => {
signal = init?.signal;
return new Promise<Response>(() => {});
}) as typeof fetch });
expect(await c.isLocalInstance()).toBe(false);
expect(signal?.aborted).toBe(true);
});
});
@@ -0,0 +1,124 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DaemonClient } from "../src/client.js";
import { terminalCommand, type TerminalDeps } from "../src/commands/terminal.js";
import { readLocalOrigin } from "../src/local-origin.js";
import { sharedTuiTarget } from "../src/shared-tui.js";
import { openTerminalWindow, type WindowDeps } from "../src/terminal-window.js";
import { shellQuote } from "../src/cross-host-executor.js";
vi.mock("../src/local-origin.js", () => ({ readLocalOrigin: vi.fn(() => "local-instance") }));
vi.mock("../src/daemon-lifecycle.js", async () => ({
...await vi.importActual("../src/daemon-lifecycle.js"),
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })),
getDaemonUrl: vi.fn(() => "http://127.0.0.1:7433"),
}));
beforeEach(() => {
vi.mocked(readLocalOrigin).mockClear().mockReturnValue("local-instance");
vi.stubEnv("OPENRIG_SESSION_NAME", "");
vi.stubEnv("RIGGED_SESSION_NAME", "");
vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
});
afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); process.exitCode = undefined; });
// A port forward serves the remote daemon's identity and bindings on loopback.
// Use the real transport/identity path; only HTTP responses and local launches are inert.
function forwardedClient(serverArgs: string[], mode: "inherit" | "isolated" | undefined, targetIdentity = "remote-instance") {
// A pending request must never override the effective mode of surviving seats.
const tmux = { serverArgs, ...(mode ? { mode, requestedMode: mode === "inherit" ? "isolated" : "inherit", migrationPending: true } : {}) };
const pane = { seat: "namesake", label: "namesake", paneCommand: ["tmux", ...serverArgs, "attach", "-t", "namesake"].map(shellQuote).join(" ") };
const fetchImpl = vi.fn(async (input: string | URL | Request) => {
const route = new URL(String(input)).pathname;
const routes: Record<string, unknown> = {
"/healthz": { selfHostId: targetIdentity },
"/api/terminal/status": { tmux },
"/api/ps": [{ rigId: "remote-kernel", rigName: "kernel" }],
"/api/rigs/remote-kernel/nodes": [{ logicalId: "operator.human", runtime: "terminal",
canonicalSessionName: "namesake", tmuxAttachCommand: pane.paneCommand, tmuxServerArgs: serverArgs, tmuxServerMode: mode }],
"/api/terminal/preview": { planId: "remote-plan", status: {}, tmux, composed: {
id: "kernel", localTmuxServer: serverArgs, opened: [pane],
pages: [[pane]], absent: [], degraded: [],
} },
};
return new Response(JSON.stringify(routes[route] ?? {}));
});
return { client: new DaemonClient("http://127.0.0.1:7433", { fetchImpl: fetchImpl as typeof fetch }), fetchImpl };
}
describe.each([{ serverArgs: [] }, { serverArgs: ["-S", "/private/remote.sock"] }])("forwarded isolated tmux attachment ($serverArgs)", ({ serverArgs }) => {
it("refuses terminal attach before spawning a local namesake", async () => {
const { client, fetchImpl } = forwardedClient(serverArgs, "isolated");
const attachTmux = vi.fn(async () => 0);
const deps: TerminalDeps = { lifecycleDeps: {} as TerminalDeps["lifecycleDeps"], clientFactory: () => client, attachTmux };
await new Command().addCommand(terminalCommand(deps)).parseAsync(["terminal", "attach", "namesake"], { from: "user" });
expect(attachTmux).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("instance identity"));
expect(fetchImpl.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual(["/api/terminal/status", "/healthz"]);
});
it("refuses to resolve a shared terminal from the forwarded daemon", async () => {
const { client, fetchImpl } = forwardedClient(serverArgs, "isolated");
await expect(sharedTuiTarget(client)).rejects.toThrow("instance identity");
expect(fetchImpl.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual(["/api/ps", "/api/rigs/remote-kernel/nodes", "/healthz"]);
});
it("refuses a desktop window before launching or creating a local viewer", async () => {
const { client, fetchImpl } = forwardedClient(serverArgs, "isolated");
const deps: WindowDeps = { platform: "linux", env: {}, exists: vi.fn(() => false), exec: vi.fn(async () => ""),
launch: vi.fn(async () => {}), sleep: vi.fn(async () => {}), id: () => "unused", herdrConfig: vi.fn(() => "unused") };
const result = await openTerminalWindow(client, "saved:kernel", "tmux", deps);
expect(result).toMatchObject({ ok: false, opened: [], windowAttempted: false });
expect(result.error).toContain("instance identity");
expect(deps.exec).not.toHaveBeenCalled();
expect(deps.launch).not.toHaveBeenCalled();
expect(fetchImpl.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual(["/api/terminal/preview", "/healthz"]);
});
});
async function openLocalAttachments(client: DaemonClient, serverArgs: string[]) {
const attachTmux = vi.fn(async () => 0);
const deps: TerminalDeps = { lifecycleDeps: {} as TerminalDeps["lifecycleDeps"], clientFactory: () => client, attachTmux };
await new Command().addCommand(terminalCommand(deps)).parseAsync(["terminal", "attach", "namesake"], { from: "user" });
expect(attachTmux).toHaveBeenCalledExactlyOnceWith({ session: "namesake", serverArgs, readOnly: false });
expect(process.exitCode).toBe(0);
await expect(sharedTuiTarget(client)).resolves.toEqual({ session: "namesake", serverArgs });
const exec = vi.fn(async (file: string, args: string[]) => {
if (file === "/bin/sh") return "/fixture/tmux";
if (file === "/usr/bin/osascript") return "window";
if (args.at(-1) === "default-size") return "120x40";
if (args.at(-1) === "window-size") return "latest";
if (args.includes("#{pane_id}")) return "%1";
return "";
});
const windowDeps: WindowDeps = { platform: "darwin", env: { TERM_PROGRAM: "Apple_Terminal" }, exists: () => false,
exec, launch: vi.fn(async () => {}), sleep: vi.fn(async () => {}), id: () => "inherit-check", herdrConfig: vi.fn(() => "unused") };
const result = await openTerminalWindow(client, "saved:kernel", "tmux", windowDeps);
expect(result).toMatchObject({ ok: true, opened: ["namesake"], window: { app: "Terminal" } });
expect(exec).toHaveBeenCalledWith("/fixture/tmux", [...serverArgs, "new-session", "-d", "-s", "openrig-view-inherit-check", "-n", "view-1", "-P", "-F", "#{pane_id}", expect.any(String)]);
}
describe.each([
{ mode: undefined, serverArgs: [] },
{ mode: "inherit" as const, serverArgs: [] },
{ mode: "inherit" as const, serverArgs: ["-L", "named"] },
{ mode: "inherit" as const, serverArgs: ["-S", "/private/inherited socket"] },
])("legacy and effective inherit attachments ($mode, $serverArgs)", ({ mode, serverArgs }) => {
it.each(["different-local-instance", null])("keeps all three local paths working with local identity %j", async origin => {
vi.mocked(readLocalOrigin).mockReturnValue(origin);
const { client, fetchImpl } = forwardedClient(serverArgs, mode);
await openLocalAttachments(client, serverArgs);
expect(readLocalOrigin).not.toHaveBeenCalled();
expect(fetchImpl.mock.calls.some(([url]) => new URL(String(url)).pathname === "/healthz")).toBe(false);
});
});
it("opens all three isolated attachments when the instance identity matches", async () => {
const serverArgs = ["-S", "/private/local-instance.sock"];
const { client, fetchImpl } = forwardedClient(serverArgs, "isolated", "local-instance");
await openLocalAttachments(client, serverArgs);
expect(fetchImpl.mock.calls.filter(([url]) => new URL(String(url)).pathname === "/healthz")).toHaveLength(3);
});
+13 -5
View File
@@ -5,7 +5,7 @@ import { tuiCommand } from "../src/commands/tui.js";
import { probeFrontDoor } from "../src/front-door.js";
function client(rigs: unknown, nodes: unknown, status = 200) {
return { get: vi.fn(async <T>(path: string) => ({ status, data: (path === "/api/ps" ? rigs : nodes) as T })) };
return { isLocalInstance: vi.fn(async () => true), get: vi.fn(async <T>(path: string) => ({ status, data: (path === "/api/ps" ? rigs : nodes) as T })) };
}
const kernel = [{ rigId: "selected-instance-kernel", rigName: "kernel" }];
const terminal = { podId: "database-pod-id", logicalId: "operator.human", runtime: "terminal", canonicalSessionName: "actual-terminal", tmuxAttachCommand: "tmux attach -t actual-terminal" };
@@ -19,9 +19,16 @@ describe("shared kernel TUI", () => {
});
it("uses the selected daemon's binding, without creating a session", async () => {
const c = client(kernel, [terminal]);
expect(await sharedTuiTarget(c)).toBe("actual-terminal");
expect(await sharedTuiTarget(c)).toEqual({ session: "actual-terminal", serverArgs: [] });
expect(c.get.mock.calls.map(([p]) => p)).toEqual(["/api/ps", "/api/rigs/selected-instance-kernel/nodes"]);
});
it("uses structured server metadata even when the caller's tmux server differs", async () => {
const serverArgs = ["-S", "/private/openrig instance/socket"];
expect(await sharedTuiTarget(client(kernel, [{ ...terminal, tmuxServerArgs: serverArgs }])))
.toEqual({ session: "actual-terminal", serverArgs });
await expect(sharedTuiTarget(client(kernel, [{ ...terminal, tmuxServerArgs: ["kill-server"] }])))
.rejects.toThrow("invalid tmux server options");
});
it("distinguishes no kernel, no terminal, and a failed read", async () => {
await expect(sharedTuiTarget(client([], []))).rejects.toThrow("No kernel");
await expect(sharedTuiTarget(client(kernel, [{ ...terminal, runtime: "codex" }]))).rejects.toThrow("no bound shared terminal");
@@ -29,7 +36,7 @@ describe("shared kernel TUI", () => {
await expect(sharedTuiTarget(client([...kernel, ...kernel], [terminal]))).rejects.toThrow("Multiple kernels");
});
it("does not attach a local namesake for a remote daemon", async () => {
const c = { ...client(kernel, [terminal]), baseUrl: "http://other-machine:7433" };
const c = { ...client(kernel, [terminal]), baseUrl: "http://other-machine:7433", isLocalInstance: vi.fn(async () => false) };
await expect(sharedTuiTarget(c)).rejects.toThrow("local tmux");
expect(c.get).not.toHaveBeenCalled();
});
@@ -37,9 +44,10 @@ describe("shared kernel TUI", () => {
const attachShared = vi.fn(async () => 0);
const launchTui = vi.fn(async () => 0);
const exit = vi.fn();
const cli = new Command().addCommand(tuiCommand({ stdoutIsTTY: true, stdinIsTTY: true, sharedTarget: async () => "actual-terminal", attachShared, launchTui, exit, err: () => {} }));
const target = { session: "actual-terminal", serverArgs: ["-S", "/private/openrig.sock"] };
const cli = new Command().addCommand(tuiCommand({ stdoutIsTTY: true, stdinIsTTY: true, sharedTarget: async () => target, attachShared, launchTui, exit, err: () => {} }));
await cli.parseAsync(["node", "rig", "tui", "--shared"]);
expect(attachShared).toHaveBeenCalledWith("actual-terminal");
expect(attachShared).toHaveBeenCalledWith(target);
expect(launchTui).not.toHaveBeenCalled();
expect(exit).toHaveBeenCalledWith(0);
});
@@ -26,6 +26,14 @@ describe("SWEEP-c — boot-only config keys warn while the daemon runs", () => {
expect(out).toMatch(/next daemon restart|restart the daemon/i);
});
it("tmux selection reports both restart and pending live-session migration", async () => {
await runSet("terminal.tmux_server", "isolated", true);
const out = errSpy.mock.calls.map(c => c.join(" ")).join("\n");
expect(out).toMatch(/next daemon restart/);
expect(out).toMatch(/surviving sessions keep their previous server/);
expect(out).toContain("rig terminal status");
});
it("a non-boot key prints NO notice; boot key with daemon down prints NO notice", async () => {
await runSet("workspace.root", "/tmp/x", true);
await runSet("daemon.port", "9999", false);
@@ -14,6 +14,8 @@ import { createHerdrSocketRpc, createHerdrSocketTransport } from "../../daemon/s
import { composeView } from "../../daemon/src/domain/terminal/view-composer.js";
import { terminalRoutes } from "../../daemon/src/routes/terminal.js";
vi.mock("../src/local-origin.js", () => ({ readLocalOrigin: () => "window-fixture" }));
describe("terminal open request budget", () => {
it.skipIf(process.platform === "win32")("waits for two healthy page applications instead of abandoning a delivered open", async () => {
const owned = mkdtempSync(join(tmpdir(), "or-terminal-"));
@@ -65,6 +67,7 @@ describe("terminal open request budget", () => {
host: null, alive: true, readOnly: false,
})), { resolveHost: () => null, panesPerPage: HERDR_PANES_PER_PAGE });
const app = new Hono();
app.get("/healthz", c => c.json({ selfHostId: "window-fixture" }));
app.use("*", async (c, next) => {
c.set("terminalService" as never, {
openView: () => adapter.openView(view),
@@ -33,7 +33,7 @@ function fixture(options: { platform?: NodeJS.Platform; env?: NodeJS.ProcessEnv;
const windowDeps: WindowDeps = { platform: options.platform ?? "darwin", env: options.env ?? { TERM_PROGRAM: "Apple_Terminal" }, exists: () => false, exec, launch: vi.fn(async () => {}), sleep: vi.fn(async () => {}), herdrConfig: vi.fn(() => "/fixture/private herdr.toml"), id: () => "fixture" };
const deps: TerminalDeps = {
lifecycleDeps: {} as TerminalDeps["lifecycleDeps"], windowDeps,
clientFactory: () => ({ baseUrl: options.remote ? "http://192.0.2.2:7433" : "http://localhost:7433", get, post }) as unknown as ReturnType<TerminalDeps["clientFactory"]>,
clientFactory: () => ({ baseUrl: options.remote ? "http://192.0.2.2:7433" : "http://localhost:7433", isLocalInstance: vi.fn(async () => !options.remote), get, post }) as unknown as ReturnType<TerminalDeps["clientFactory"]>,
};
const command = new Command().addCommand(terminalCommand(deps));
return { get, post, exec, windowDeps, run: (args: string[]) => command.parseAsync(["terminal", "open", ...args], { from: "user" }) };
@@ -1,6 +1,6 @@
// Real daemon routes, kernel composition/plan hashing, both clients and the window opener.
// HTTP stays in-process; provider availability, tmux commands and desktop launching are simulated.
import { afterEach, expect, it } from "vitest";
import { afterEach, expect, it, vi } from "vitest";
import { Hono } from "hono";
import { DaemonClient as CliClient } from "../src/client.js";
import { openTerminalWindow, type WindowDeps } from "../src/terminal-window.js";
@@ -9,6 +9,8 @@ import { terminalRoutes } from "../../daemon/src/routes/terminal.js";
import { TerminalService } from "../../daemon/src/domain/terminal/terminal-service.js";
import type { TerminalProvider } from "../../daemon/src/domain/terminal/terminal-provider.js";
vi.mock("../src/local-origin.js", () => ({ readLocalOrigin: () => "window-fixture" }));
const stdoutDescriptors = Object.getOwnPropertyDescriptors(process.stdout);
function terminalWidth(columns?: number): void {
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
@@ -39,6 +41,7 @@ function fixture() {
resolveHost: () => null, hasSession: name => alive.has(name), resolveLocalTmux: () => "/fixture/tmux",
} as ConstructorParameters<typeof TerminalService>[0]);
const app = new Hono();
app.get("/healthz", c => c.json({ selfHostId: "window-fixture" }));
app.use("*", async (c, next) => { c.set("terminalService" as never, service); await next(); });
app.route("/api/terminal", terminalRoutes());
const fetchImpl: typeof fetch = async (url, init) => app.request(String(url), init);
@@ -14,12 +14,12 @@ const planId = "0123456789abcdef".repeat(4);
const label = (id = "kernel", token = "l1") => `openrig:${id}#${planId.slice(0, 16)}#${token}`;
interface Tab { workspace_id: string; tab_id: string; label: string }
const spaceLabels: Record<string, string> = { "openrig-kernel": "openrig kernel", "old-name": "switchboard" };
function fixture(tabs: Tab[] = [], id = "kernel", session?: string) {
function fixture(tabs: Tab[] = [], id = "kernel", session?: string, localTmuxServer?: string[]) {
const socketPath = session === undefined ? "/daemon home/herdr.sock" : `/daemon home/sessions/${session}/herdr.sock`;
const panes = composeView(id, ["tui", "advisor", "operator"].map(seat => ({ seat, label: seat,
tmuxSession: `fixture-${seat}`, host: null, readOnly: false, alive: true,
})), { resolveHost: () => null, localTmux: "/fixture/bin/tmux" }).opened;
const composed = { id, opened: panes, pages: [panes], absent: [] as Array<{ seat: string; host: string | null; reason: string }>, degraded: [] as Array<{ seat: string; host: string; reason: string }> };
})), { resolveHost: () => null, localTmux: "/fixture/bin/tmux", localTmuxServer }).opened;
const composed = { id, localTmuxServer, opened: panes, pages: [panes], absent: [] as Array<{ seat: string; host: string | null; reason: string }>, degraded: [] as Array<{ seat: string; host: string; reason: string }> };
const get = vi.fn(async (url: string) => ({ status: 200, data: url.includes("preview") ? {
planId, composed, ...(session !== undefined ? { session } : {}),
status: { launch: { socketPath, ...(session !== undefined ? { session } : {}) } },
@@ -57,7 +57,7 @@ function fixture(tabs: Tab[] = [], id = "kernel", session?: string) {
};
const deps: TerminalDeps = {
lifecycleDeps: {} as TerminalDeps["lifecycleDeps"], windowDeps,
clientFactory: () => ({ baseUrl: "http://localhost:7433", get, post }) as unknown as ReturnType<TerminalDeps["clientFactory"]>,
clientFactory: () => ({ baseUrl: "http://localhost:7433", isLocalInstance: vi.fn(async () => true), get, post }) as unknown as ReturnType<TerminalDeps["clientFactory"]>,
};
return { tabs, composed, workspaces, list, focus, paneList, processInfo, clients, exec, post, get, socketPath, windowDeps, run: (args: string[] = ["--json"]) => new Command().addCommand(terminalCommand(deps)).parseAsync(["terminal", "open", `saved:${id}`, ...args], { from: "user" }) };
}
@@ -73,6 +73,15 @@ describe("reopening a Herdr view in a desktop window", () => {
});
afterEach(() => { process.exitCode = originalExit; vi.restoreAllMocks(); });
it("confirms Herdr clients only on the daemon's selected server", async () => {
const socket = "/private/openrig instance.sock";
const f = fixture([{ workspace_id: "old", tab_id: "first", label: label() }], "kernel", undefined, ["-S", socket]);
await f.run();
expect(f.focus).toHaveBeenCalledExactlyOnceWith("first");
expect(f.post).not.toHaveBeenCalled();
expect(f.exec).toHaveBeenCalledWith("/fixture/bin/tmux", ["-S", socket, "list-clients", "-F", "#{client_pid}\t#{session_name}\t#{client_readonly}"]);
});
it.each([[undefined, true], ["daemon-named", true], [undefined, false], ["daemon-named", false]] as const)("following binds the exact custom endpoint before tab selection (session=%s matches=%s)", async (session, matches) => {
const f = fixture([{ workspace_id: "follow-w", tab_id: "follow-t", label: "following" }], "kernel", session);
const namedSocket = "/run private/custom.sock";
@@ -44,7 +44,7 @@ it.each([
seat, label: seat, tmuxSession: seat, host: null, readOnly: true, alive: true,
})), { resolveHost: () => null, panesPerPage: 16, localTmux: wrapper });
expect(composed.pages.map(page => page.length)).toEqual([16, 3]);
const client = { baseUrl: "http://localhost:7433", get: async () => ({ status: 200, data: { planId: "fixture", status: {}, composed } }) } as unknown as DaemonClient;
const client = { baseUrl: "http://localhost:7433", isLocalInstance: vi.fn(async () => true), get: async () => ({ status: 200, data: { planId: "fixture", status: {}, composed } }) } as unknown as DaemonClient;
const desktop = vi.fn(async () => "window");
const beforeSplits: string[] = [];
const deps: WindowDeps = {
@@ -84,7 +84,7 @@ it.each([false, true])("opens real viewer panes and preserves source processes (
for (const name of names) tmux(source, ["new-session", "-d", "-s", name, "sleep 60"]);
const original = tmux(source, ["list-panes", "-a", "-F", "#{session_name} #{pane_id} #{pane_pid}"]);
const panes = names.map(seat => ({ seat, label: seat, paneCommand: `tmux -L ${source} attach-session -t '=${seat}'` }));
const client = { baseUrl: "http://localhost:7433", get: async () => ({ status: 200, data: { planId: "fixture", status: {}, composed: { opened: panes, pages: paged ? panes.map(pane => [pane]) : [panes], columns: paged ? 1 : 3, absent: [], degraded: [] } } }) } as unknown as DaemonClient;
const client = { baseUrl: "http://localhost:7433", isLocalInstance: vi.fn(async () => true), get: async () => ({ status: 200, data: { planId: "fixture", status: {}, composed: { opened: panes, pages: paged ? panes.map(pane => [pane]) : [panes], columns: paged ? 1 : 3, absent: [], degraded: [] } } }) } as unknown as DaemonClient;
const desktop = vi.fn(async () => "window");
const deps: WindowDeps = {
herdrConfig: () => { throw new Error("tmux must not prepare Herdr config"); },
@@ -127,7 +127,7 @@ it.each([80, 119, 120, 174])("opens the measured %i-column welcome in an owned v
const pages = width >= 120 ? [[panes[1]!, panes[0]!], [panes[2]!]] : panes.map(pane => [pane]);
return { status: 200, data: { planId: "fixture", status: {}, composed: { id: "kernel", kernelLayout: "dual-runtime", opened: pages.flat(), pages, columns: width >= 120 ? 2 : 1, absent: [], degraded: [] } } };
});
const client = { baseUrl: "http://localhost:7433", get } as unknown as DaemonClient;
const client = { baseUrl: "http://localhost:7433", isLocalInstance: vi.fn(async () => true), get } as unknown as DaemonClient;
const desktop = vi.fn(async () => `window:${columns}`);
const deps: WindowDeps = {
herdrConfig: () => { throw new Error("tmux must not prepare Herdr config"); },
@@ -4,7 +4,7 @@
// localhost, the TUI and CLI daemon clients, and the window opener's plain-tmux fallback on an isolated tmux server.
// Stubbed: the provider adapters' status and liveness, the Herdr binary probe, and the macOS desktop launcher
// (osascript), which records its command instead of opening a window.
import { afterEach, expect, it } from "vitest";
import { afterEach, expect, it, vi } from "vitest";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -20,6 +20,8 @@ import { TerminalService } from "../../daemon/src/domain/terminal/terminal-servi
import { HERDR_PANES_PER_PAGE } from "../../daemon/src/domain/terminal/herdr-adapter.js";
import type { TerminalProvider } from "../../daemon/src/domain/terminal/terminal-provider.js";
vi.mock("../src/local-origin.js", () => ({ readLocalOrigin: () => "window-fixture" }));
const env = { ...process.env }; delete env["TMUX"];
const sockets: string[] = [];
const dirs: string[] = [];
@@ -64,6 +66,7 @@ async function fixture(herdrAvailable: boolean) {
resolveHost: () => null, hasSession: () => true, resolveLocalTmux: () => wrapper,
} as ConstructorParameters<typeof TerminalService>[0]);
const app = new Hono();
app.get("/healthz", c => c.json({ selfHostId: "window-fixture" }));
app.use("*", async (c, next) => { (c.set.bind(c) as (k: string, v: unknown) => void)("terminalService", service); await next(); });
app.route("/api/terminal", terminalRoutes());
const port = await new Promise<number>(resolve => {
+21 -4
View File
@@ -6,11 +6,11 @@ import { composeView } from "../../daemon/src/domain/terminal/view-composer.js";
function fixture(options: { herdr?: boolean; ghostty?: string; refusal?: number; empty?: boolean; alive?: boolean } = {}) {
const panes = ["tui", "advisor", "operator"].map(seat => ({ seat, label: seat, paneCommand: `tmux attach-session -t '=fixture-${seat}'` }));
const composed = { id: "kernel", opened: options.empty ? [] : panes, pages: options.empty ? [] : [panes], columns: 3, absent: [], degraded: [] };
const composed = { id: "kernel", localTmuxServer: undefined as string[] | undefined, opened: options.empty ? [] : panes, pages: options.empty ? [] : [panes], columns: 3, absent: [], degraded: [] };
const preview = { planId: "bound-plan", composed, status: { launch: { socketPath: "/daemon home/herdr.sock", session: "daemon-session" } } };
const get = vi.fn(async (url: string): Promise<{ status: number; data: unknown }> => ({ status: 200, data: url.includes("preview") ? preview : { providers: [{ liveness: { alive: options.alive !== false } }] } }));
const post = vi.fn(async (): Promise<{ status: number; data: unknown }> => options.refusal ? { status: options.refusal, data: { error: "view changed" } } : { status: 200, data: { provider: "herdr", ok: true, opened: panes.map(p => p.seat), absent: [], degraded: [], pages: 1 } });
const client = { baseUrl: "http://localhost:7433", get, post } as unknown as DaemonClient;
const client = { baseUrl: "http://localhost:7433", isLocalInstance: vi.fn(async () => true), get, post } as unknown as DaemonClient;
let pane = 0;
const exec = vi.fn(async (file: string, args: string[]) => {
if (file === "/usr/bin/env" && args.at(-1) === "list") return JSON.stringify({ result: { workspaces: [] } });
@@ -22,7 +22,7 @@ function fixture(options: { herdr?: boolean; ghostty?: string; refusal?: number;
if (file === "/usr/bin/osascript") return options.ghostty === "1.3.0" ? "tab" : "window";
if (file === "/bin/sh") return args[1] === "command -v herdr" ? "/fixture/bin/herdr" : "/fixture/bin/tmux";
if (args.at(-1) === "default-size") return "120x40";
if (args[0] === "show-options" && args.at(-1) === "window-size") return "latest";
if (args.includes("show-options") && args.at(-1) === "window-size") return "latest";
if (args.includes("#{pane_id}")) return `%${++pane}`;
return "";
});
@@ -31,6 +31,23 @@ function fixture(options: { herdr?: boolean; ghostty?: string; refusal?: number;
}
describe("desktop terminal view", () => {
it("creates and attaches its viewer on the server named by the daemon preview", async () => {
const f = fixture({ herdr: false });
const socket = "/private/openrig instance.sock";
f.preview.composed.localTmuxServer = ["-S", socket];
for (const pane of f.preview.composed.opened) {
pane.paneCommand = `tmux -S '${socket}' attach -t '=fixture-${pane.seat}'`;
}
f.deps.env.TMUX = "/unrelated/default,1,0";
const result = await openTerminalWindow(f.client, "saved:kernel", "tmux", f.deps);
expect(result.ok).toBe(true);
const commands = f.exec.mock.calls.filter(([file]) => file === "/fixture/bin/tmux");
expect(commands.length).toBeGreaterThan(0);
for (const [, args] of commands) expect(args.slice(0, 2)).toEqual(["-S", socket]);
expect(f.exec.mock.calls.find(([file]) => file === "/usr/bin/osascript")![1].join(" "))
.toContain(`-S '${socket}' attach-session -t '=openrig-view-owned-test'`);
});
it.each([
{ TERM_PROGRAM: "Apple_Terminal" },
{ TERM_PROGRAM: "tmux", __CFBundleIdentifier: "com.apple.Terminal" },
@@ -319,7 +336,7 @@ describe("desktop terminal view", () => {
it.each(["remote", "headless", "empty"])("%s makes no window or tmux mutations", async reason => {
const f = fixture({ empty: reason === "empty" });
if (reason === "remote") Object.assign(f.client, { baseUrl: "http://192.0.2.2:7433" });
if (reason === "remote") Object.assign(f.client, { baseUrl: "http://192.0.2.2:7433", isLocalInstance: vi.fn(async () => false) });
if (reason === "headless") Object.assign(f.deps, { platform: "linux", env: {} });
const result = await openTerminalWindow(f.client, "saved:kernel", undefined, f.deps);
expect(result.ok).toBe(false);
+42 -2
View File
@@ -29,6 +29,7 @@ function makeDeps(opts?: { routes?: Record<string, StubResponse> }): {
deps: {
lifecycleDeps: {} as TerminalDeps["lifecycleDeps"],
clientFactory: () => ({
isLocalInstance: vi.fn(async () => true),
get: vi.fn(async (path: string) => {
calls.push({ method: "GET", path });
return routes[`GET ${path}`] ?? { status: 200, data: {} };
@@ -79,12 +80,51 @@ describe("rig terminal CLI", () => {
process.exitCode = undefined;
});
it("registers open/views/status subcommands", () => {
it("registers open/views/status/attach subcommands", () => {
const { deps } = makeDeps();
const program = createProgram({ terminalDeps: deps });
const cmd = program.commands.find((c) => c.name() === "terminal");
expect(cmd).toBeDefined();
expect(cmd!.commands.map((c) => c.name()).sort()).toEqual(["open", "status", "views"]);
expect(cmd!.commands.map((c) => c.name()).sort()).toEqual(["attach", "open", "status", "views"]);
});
it.each([false, true])("attaches using the daemon's effective server, preserving read-only=%s", async readOnly => {
const serverArgs = ["-S", "/private/selected server.sock"];
const { deps, calls } = makeDeps({ routes: { "GET /api/terminal/status": {
status: 200, data: { tmux: { mode: "inherit", requestedMode: "isolated", migrationPending: true, serverArgs } },
} } });
deps.attachTmux = vi.fn(async () => 7);
await createProgram({ terminalDeps: deps }).parseAsync([
"node", "rig", "terminal", "attach", ...(readOnly ? ["--read-only"] : []), "--", "seat'quoted",
]);
expect(deps.attachTmux).toHaveBeenCalledWith({ session: "seat'quoted", serverArgs, readOnly });
expect(calls).toEqual([{ method: "GET", path: "/api/terminal/status" }]);
expect(process.exitCode).toBe(7);
});
it.each([
{ status: 503, data: {}, message: "HTTP 503" },
{ status: 200, data: {}, message: "cannot report its tmux server" },
{ status: 200, data: { tmux: { serverArgs: ["kill-server"] } }, message: "invalid tmux server options" },
])("reports unreadable or invalid server metadata without attaching: $message", async response => {
const { deps } = makeDeps({ routes: { "GET /api/terminal/status": response } });
deps.attachTmux = vi.fn(async () => 0);
await createProgram({ terminalDeps: deps }).parseAsync(["node", "rig", "terminal", "attach", "seat"]);
expect(deps.attachTmux).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(expect.stringContaining(response.message));
expect(process.exitCode).toBe(1);
});
it("does not attach a local namesake for an HTTP remote daemon", async () => {
const { deps, calls } = makeDeps();
const factory = deps.clientFactory;
deps.clientFactory = url => Object.assign(factory(url), { baseUrl: "http://remote:7433", isLocalInstance: vi.fn(async () => false) });
deps.attachTmux = vi.fn(async () => 0);
await createProgram({ terminalDeps: deps }).parseAsync(["node", "rig", "terminal", "attach", "seat"]);
expect(calls).toEqual([]);
expect(deps.attachTmux).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("local tmux"));
expect(process.exitCode).toBe(1);
});
it("--window uses local desktop handling and reports a headless refusal without a provider POST", async () => {
+46
View File
@@ -0,0 +1,46 @@
import { EventEmitter } from "node:events";
import { spawn } from "node:child_process";
import { afterEach, describe, expect, it, vi } from "vitest";
import { attachTmux, readTmuxServerArgs } from "../src/tmux-attachment.js";
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); });
describe("tmux attachment", () => {
it.each([false, true])("passes the exact server and session as argv and clears nesting markers (read-only=%s)", async readOnly => {
vi.stubEnv("TMUX", "/other/server,7,0");
vi.stubEnv("TMUX_PANE", "%1");
const child = new EventEmitter();
vi.mocked(spawn).mockReturnValue(child as ReturnType<typeof spawn>);
const result = attachTmux({ session: "-seat ' quoted", serverArgs: ["-S", "/private/instance socket"], readOnly });
expect(spawn).toHaveBeenCalledWith("tmux", [
"-S", "/private/instance socket", "attach-session", ...(readOnly ? ["-r"] : []), "-t", "=-seat ' quoted",
], expect.objectContaining({ stdio: "inherit" }));
const env = vi.mocked(spawn).mock.calls[0]![2]!.env!;
expect(env.TMUX).toBeUndefined();
expect(env.TMUX_PANE).toBeUndefined();
expect(process.env.TMUX).toBe("/other/server,7,0");
child.emit("exit", 3);
await expect(result).resolves.toBe(3);
});
it("surfaces spawn failures and signal exits", async () => {
const child = new EventEmitter();
vi.mocked(spawn).mockReturnValue(child as ReturnType<typeof spawn>);
const failed = attachTmux({ session: "seat", serverArgs: [] });
const assertion = expect(failed).rejects.toThrow("tmux unavailable");
child.emit("error", new Error("tmux unavailable"));
await assertion;
const signaled = attachTmux({ session: "seat", serverArgs: [] });
child.emit("exit", null);
await expect(signaled).resolves.toBe(1);
});
it.each([null, ["-S"], ["-S", ""], ["-S", "bad\0socket"], ["-L", 7], ["-S", "socket", "kill-server"]])(
"rejects malformed server metadata: %j", value => {
expect(() => readTmuxServerArgs(value)).toThrow("invalid tmux server options");
expect(spawn).not.toHaveBeenCalled();
},
);
});
@@ -0,0 +1,90 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { readRecordedTmuxServerArgs } from "../src/tmux-attachment.js";
import { selectTmuxServer } from "../../daemon/src/domain/tmux-server-selection.js";
describe("offline tmux server selection", () => {
let home: string;
let file: string;
let marker: string;
beforeEach(() => { home = mkdtempSync(join(tmpdir(), "t")); file = join(home, "tmux-server.json"); marker = join(home, "tmux-server.initialized"); });
afterEach(() => rmSync(home, { recursive: true, force: true }));
it.each([undefined, false])("ignores an advisory inherit socket, including an old isolated socket (pinned=%j)", pinned => {
const socket = join(home, "stale inherited socket");
writeFileSync(file, JSON.stringify({ version: 1, home: realpathSync(home), mode: "inherit",
socketPath: socket, pinned, isolatedSocketPath: join(home, "previously-isolated") }));
// Opt-out writes the advisory record before removing the old active marker.
writeFileSync(marker, pinned === false ? "openrig-tmux-pinned-v1\n" : "1\n");
expect(readRecordedTmuxServerArgs(home)).toEqual([]);
});
it("keeps legacy behavior when no daemon has written a server record", () => {
expect(readRecordedTmuxServerArgs(home)).toEqual([]);
});
describe.each([false, true])("unreadable selection with active pin=%s", pinned => {
it.each(["missing", "malformed", "invalid", "unreadable"])("handles a %s record without guessing a pinned socket", state => {
writeFileSync(marker, pinned ? "openrig-tmux-pinned-v1\n" : "1\n");
if (state === "malformed") writeFileSync(file, "{");
if (state === "invalid") writeFileSync(file, JSON.stringify({ version: 2 }));
if (state === "unreadable") mkdirSync(file);
if (pinned) expect(() => readRecordedTmuxServerArgs(home)).toThrow(/tmux server record/);
else expect(readRecordedTmuxServerArgs(home)).toEqual([]);
});
});
it("does not guess whether an unreadable marker contains an active pin", () => {
mkdirSync(marker);
expect(() => readRecordedTmuxServerArgs(home)).toThrow("Cannot read the tmux server pin marker");
});
it("keeps legacy cleanup after a default inherit advisory record is lost", async () => {
const selected = await selectTmuxServer({ home, mode: "inherit", registeredSessions: [], platform: "linux", uid: 1000,
env: { TMUX: `${join(home, "inherited.sock")},1,0`, TMUX_TMPDIR: home } });
expect(selected.serverArgs).toEqual(["-S", join(home, "inherited.sock")]);
expect(readRecordedTmuxServerArgs(home)).toEqual([]);
unlinkSync(file);
expect(readRecordedTmuxServerArgs(home)).toEqual([]);
});
it.each(["isolated", "inherit"] as const)("pins cleanup for effective %s during an isolation request and refuses a lost record", async mode => {
const selected = await selectTmuxServer({ home, mode: "isolated", registeredSessions: mode === "inherit" ? ["seat"] : [],
platform: "linux", uid: 1000, env: { TMUX: `${join(home, "inherited.sock")},1,0`, TMUX_TMPDIR: home },
inspect: async () => ({ state: "available", sessions: [{ name: "seat", home }] }) });
expect(selected.mode).toBe(mode);
expect(readRecordedTmuxServerArgs(home)).toEqual(selected.executionArgs);
unlinkSync(file);
expect(() => readRecordedTmuxServerArgs(home)).toThrow("Missing tmux server record");
});
it("releases the cleanup pin after an isolated server drains and inheritance resumes", async () => {
const options = { home, registeredSessions: ["seat"], platform: "linux" as const, uid: 1000,
env: { TMUX: `${join(home, "inherited.sock")},1,0`, TMUX_TMPDIR: home }, inspect: async () => ({ state: "absent" as const }) };
await selectTmuxServer({ ...options, mode: "isolated" });
const selected = await selectTmuxServer({ ...options, mode: "inherit" });
expect(selected).toMatchObject({ mode: "inherit", executionArgs: [], migrationPending: false });
expect(readRecordedTmuxServerArgs(home)).toEqual([]);
expect(existsSync(marker)).toBe(false);
unlinkSync(file);
expect(readRecordedTmuxServerArgs(home)).toEqual([]);
});
it("keeps a legacy isolated record pinned even without the newer marker", () => {
const socket = join(home, "isolated.sock");
writeFileSync(file, JSON.stringify({ version: 1, home: realpathSync(home), mode: "isolated", socketPath: socket }));
expect(readRecordedTmuxServerArgs(home)).toEqual(["-S", socket]);
});
it.each([
{ version: 2 }, { home: "another-home" }, { mode: "unknown" }, { socketPath: "relative" }, { socketPath: "/bad\0socket" }, { pinned: false },
{ isolatedSocketPath: "relative" }, { isolatedSocketPath: "/bad\0socket" },
])("does not guess a server or overwrite an invalid record: %j", invalid => {
const data = JSON.stringify({ version: 1, home: realpathSync(home), mode: "isolated", pinned: true, socketPath: join(home, "socket"), ...invalid });
writeFileSync(file, data);
expect(() => readRecordedTmuxServerArgs(home)).toThrow();
expect(readFileSync(file, "utf8")).toBe(data);
});
});
+19 -8
View File
@@ -23,7 +23,8 @@ import type { ArgvExecFn } from "./tmux.js";
* blocks tmux's hooks print on their own (an `after-list-panes` in a user's tmux.conf) are not taken for a command's
* reply, where a spawned client prints a hook's output after its command's. Anything else (a launch,
* which takes the spawning process's environment; `load-buffer`, see ROUTED; a command line that cannot be written on
* one line; a global flag; no server yet, or one before 3.2) goes to the spawned client, as before.
* one line; a global flag other than this client's server selection; no server yet, or one before 3.2) goes to the
* spawned client, as before.
*/
/** The session option that marks a control session: its value is the owner id of the daemon whose client made it. */
@@ -277,6 +278,9 @@ export type SpawnControlClient = (args: string[]) => ChildProcessWithoutNullStre
export interface TmuxControlOptions {
spawnClient?: SpawnControlClient;
/** The adapter's fixed server selection (`-S <socket>` or `-L <name>`). Empty keeps tmux's inherited server.
* Only commands carrying this exact selection may use this client's connection. */
serverArgs?: readonly string[];
/** Once the command at the head of the line has had no reply for this long, the client is ended: the reads it owed
* are asked again of a spawned client, the writes it owed fail, and the next command starts a new client. Also the
* time a client has to attach. */
@@ -320,6 +324,7 @@ export function tmuxControlExec(fallback: ArgvExecFn, options: TmuxControlOption
const replyTimeoutMs = options.replyTimeoutMs ?? 10_000;
const maxBuffer = options.maxBuffer ?? TMUX_MAX_BUFFER;
const now = options.now ?? (() => Date.now());
const serverArgs = [...(options.serverArgs ?? [])];
let client: ControlClient | null = null;
let starting: Promise<ControlClient | null> | null = null;
let failures = 0;
@@ -390,7 +395,7 @@ export function tmuxControlExec(fallback: ArgvExecFn, options: TmuxControlOption
/** The control client's arguments: a session of its own, made as it attaches and never one that exists, marked with
* this daemon's id, destroyed by tmux once no client is in it, and never moving its client elsewhere when it goes. */
const clientArgs = (): string[] => [
"-C", "new-session", "-s", controlSessionName(owner), "-x", "80", "-y", "24", "-f", "no-output,ignore-size",
...serverArgs, "-C", "new-session", "-s", controlSessionName(owner), "-x", "80", "-y", "24", "-f", "no-output,ignore-size",
"--", ...KEEP_ALIVE,
";", "set-option", CONTROL_OPTION, owner,
";", "set-option", "destroy-unattached", "on",
@@ -402,7 +407,7 @@ export function tmuxControlExec(fallback: ArgvExecFn, options: TmuxControlOption
* started: it never starts a server, nor keeps one up that would exit without it (`checkAlone`). */
const serverReady = async (): Promise<boolean> => {
try {
const rows = (await fallback(["tmux", "list-sessions", "-F", `#{version}|#{${CONTROL_OPTION}}`])).split("\n").filter(Boolean);
const rows = (await fallback(["tmux", ...serverArgs, "list-sessions", "-F", `#{version}|#{${CONTROL_OPTION}}`])).split("\n").filter(Boolean);
return supportsControlMode(rows[0] ?? "") && rows.some((row) => row.endsWith("|"));
} catch {
return false;
@@ -513,7 +518,7 @@ export function tmuxControlExec(fallback: ArgvExecFn, options: TmuxControlOption
const t = command.indexOf("-t");
if (err.message.includes("no reply within") || t < 0 || t + 1 >= command.length) return err;
try {
await fallback(["tmux", "display-message", "-p", "-t", command[t + 1]!, ""]);
await fallback(["tmux", ...serverArgs, "display-message", "-p", "-t", command[t + 1]!, ""]);
return err;
} catch (probe) {
const stderr = (probe as { execStderr?: unknown }).execStderr;
@@ -524,8 +529,13 @@ export function tmuxControlExec(fallback: ArgvExecFn, options: TmuxControlOption
const exec = async (argvIn: string[]): Promise<string> => {
const [bin, ...argsIn] = argvIn;
const commandsIn = commandsOf(argsIn);
const routable = bin === "tmux" && argsIn.length > 0 && !argsIn[0]!.startsWith("-") && commandsIn.length > 0
// Only the leading global server pair is a selection: capture-pane's own -S is a scrollback bound.
// A different server (or any other global flags) belongs to a spawned client, unchanged.
const prefix = argsIn.length >= 2 && (argsIn[0] === "-S" || argsIn[0] === "-L") ? argsIn.slice(0, 2) : [];
const args = argsIn.slice(prefix.length);
const sameServer = prefix.length === serverArgs.length && prefix.every((arg, i) => arg === serverArgs[i]);
const commandsIn = sameServer && !args[0]?.startsWith("-") ? commandsOf(args) : [];
const routable = bin === "tmux" && commandsIn.length > 0
&& commandsIn.every((c) => ROUTED.has(c[0]!) && namesItsTarget(c))
&& argsIn.every((a) => !/[\n\r\0]/.test(a));
// Every listing leaves this daemon's own rows out, from the first one on: its session may be made by the very call
@@ -533,13 +543,14 @@ export function tmuxControlExec(fallback: ArgvExecFn, options: TmuxControlOption
// exists the output is unchanged.
const marked = bin === "tmux" && commandsIn.some((c) => LISTINGS.has(c[0]!));
const tag = marked ? ownRowTag() : "";
const argv = marked ? [bin, ...markOwnRows(argsIn, owner, tag)] : argvIn;
const commandArgs = marked ? markOwnRows(args, owner, tag) : args;
const argv = marked ? [bin, ...prefix, ...commandArgs] : argvIn;
const own = (text: string): string => marked ? withoutOwnRows(text, tag) : text;
const target = routable ? await connected() : null;
if (!target) return own(await fallback(argv));
target.users++;
try {
const commands = commandsOf(argv.slice(1));
const commands = commandsOf(commandArgs);
let stdout = "";
let bytes = 0;
for (const [index, command] of commands.entries()) {
+15 -8
View File
@@ -1,12 +1,12 @@
// Which tmux server the daemon's sessions live on, as the flags a person's attach command needs.
//
// The daemon runs every tmux command without -L or -S, so tmux uses the server named by the
// daemon's inherited $TMUX when it was started from inside tmux, and otherwise the default
// server. A person attaching from a new terminal reaches the default server, so an attach
// command printed for a daemon on another server must name that server.
// Startup selects either the inherited server or this OpenRig home's dedicated socket.
// Inherited commands keep tmux's normal environment-based selection; attachment metadata
// names that server for a new terminal. Isolation fixes both paths to the recorded socket.
import fs from "node:fs";
import path from "node:path";
import type { TmuxServerSelection } from "../domain/tmux-server-selection.js";
function realpath(p: string): string {
try {
@@ -25,7 +25,7 @@ function realpath(p: string): string {
*/
export function tmuxServerArgs(env: NodeJS.ProcessEnv, uid: number | undefined = process.getuid?.()): string[] {
// tmux puts the socket path first, as is: keep any whitespace in it.
const socket = env["TMUX"]?.split(",")[0];
const socket = env["TMUX"]?.replace(/,\d+,\d+$/, "");
if (!socket?.trim()) return [];
const tmpdir = env["TMUX_TMPDIR"]?.trim() ?? "";
const customTmpdir = tmpdir !== "" && realpath(tmpdir) !== realpath("/tmp");
@@ -41,13 +41,20 @@ export function tmuxServerArgs(env: NodeJS.ProcessEnv, uid: number | undefined =
}
let daemonServerArgs: string[] = [];
let daemonServerSelection: TmuxServerSelection | undefined;
/** Recorded once at daemon startup, from the daemon's own environment. */
export function setDaemonTmuxServer(args: readonly string[]): void {
/** Recorded once at daemon startup, before reconciliation or launch. */
export function setDaemonTmuxServer(args: readonly string[], selection?: TmuxServerSelection): void {
daemonServerArgs = [...args];
daemonServerSelection = selection;
}
/** The daemon's tmux server flags; empty for the default server. */
/** Effective startup choice, including a deferred change while old seats survive. */
export function daemonTmuxServerInfo(): TmuxServerSelection | undefined {
return daemonServerSelection;
}
/** The daemon's effective server flags; empty for legacy/injected compositions. */
export function daemonTmuxServerArgs(): readonly string[] {
return daemonServerArgs;
}
+16 -6
View File
@@ -445,7 +445,7 @@ export class TmuxAdapter {
// uses. An id alone is unique only for one server's lifetime.
let created: { id: string; at: string } | null;
try {
const out = await this.exec(`tmux new-session -d -P -F ${shellQuote("#{session_id} #{session_created}")} -s ${shellQuote(name)}${cwd != null ? ` -c ${shellQuote(cwd)}` : ""}`);
const out = await this.exec(this.onServer(`tmux new-session -d -P -F ${shellQuote("#{session_id} #{session_created}")} -s ${shellQuote(name)}${cwd != null ? ` -c ${shellQuote(cwd)}` : ""}`));
const match = /^(\$\d+) (\d+)$/.exec(out.trim());
created = match ? { id: match[1]!, at: match[2]! } : null;
} catch (err) {
@@ -478,7 +478,7 @@ export class TmuxAdapter {
private async removeCreatedSession(name: string, created: { id: string; at: string }): Promise<TmuxResult> {
const same = `#{&&:#{==:#{session_name},${name}},#{==:#{session_created},${created.at}}}`;
try {
const out = await this.exec(`tmux if-shell -F -t ${shellQuote(created.id)} ${shellQuote(same)} ${shellQuote(`kill-session -t '${created.id}'`)} ${shellQuote("display-message -p kept")}`);
const out = await this.exec(this.onServer(`tmux if-shell -F -t ${shellQuote(created.id)} ${shellQuote(same)} ${shellQuote(`kill-session -t '${created.id}'`)} ${shellQuote("display-message -p kept")}`));
if (out.includes("kept")) return { ok: false, code: "guard_target_changed", message: `its id ${created.id} no longer names it (it may already be gone); nothing was killed.` };
return { ok: true };
} catch (err) {
@@ -557,7 +557,16 @@ export class TmuxAdapter {
private exec: ExecFn,
private fileOps: TmuxFileOps = defaultTmuxFileOps(),
private argvExec?: ArgvExecFn,
) {}
serverArgs: readonly string[] = [],
) { this.serverArgs = [...serverArgs]; }
private readonly serverArgs: readonly string[];
/** Prefix one adapter-owned tmux invocation, including its chained commands.
* The daemon's explicit server wins over an inherited TMUX or wrapper socket. */
private onServer(command: string): string {
return this.serverArgs.length ? `tmux ${posixJoinArgv([...this.serverArgs])}${command.slice(4)}` : command;
}
/**
* Run a multiplexer command. `argv` is what is EXECUTED on both paths:
@@ -569,8 +578,9 @@ export class TmuxAdapter {
* preserve an exact legacy string, never to smuggle new semantics.
*/
private async run(argv: string[], legacy?: string): Promise<string> {
if (this.argvExec) return this.argvExec(argv);
return this.exec(legacy ?? posixJoinArgv(argv));
const targeted = this.serverArgs.length ? [argv[0]!, ...this.serverArgs, ...argv.slice(1)] : argv;
if (this.argvExec) return this.argvExec(targeted);
return this.exec(legacy ? this.onServer(legacy) : posixJoinArgv(targeted));
}
/** Environment-bearing commands travel in a private config, never a client's argv.
@@ -600,7 +610,7 @@ export class TmuxAdapter {
// and server bootstrap is POSIX-specific (psmux auto-starts its own).
// Native socket ownership arbitrates
// concurrent starts; the readback below, not shell exit, proves availability.
await this.exec("tmux -D </dev/null >/dev/null 2>&1 &");
await this.exec(this.onServer("tmux -D </dev/null >/dev/null 2>&1 &"));
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 25));
if ((await this.probeSession(probeName)).state !== "transport_unavailable") return { ok: true };
+5 -1
View File
@@ -27,7 +27,7 @@ import { deriveCanonicalSessionName } from "./session-name.js";
import { buildNativeResumeCommand, buildCodexResumeCore } from "./native-resume-probe.js";
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
import { readReadyFreshLaunchFences } from "./fresh-occupant-relation.js";
import { tmuxAttachCommand } from "../adapters/tmux-server.js";
import { daemonTmuxServerArgs, daemonTmuxServerInfo, tmuxAttachCommand } from "../adapters/tmux-server.js";
import { communityHarnessLabel } from "./community-harness-presets.js";
import { communityHarnessOf } from "./built-in-runtimes.js";
import { sessionCommunityHarnessSelect } from "./session-registry.js";
@@ -811,6 +811,10 @@ function buildInventoryEntry(
previousOccupant: row.previous_occupant,
handoverAt: row.handover_at,
tmuxAttachCommand: row.binding_attachment_type === "tmux" && row.session_name ? tmuxAttachCommand(row.session_name) : null,
...(row.binding_attachment_type === "tmux" && row.session_name && daemonTmuxServerArgs().length
? { tmuxServerArgs: [...daemonTmuxServerArgs()] } : {}),
...(row.binding_attachment_type === "tmux" && row.session_name && daemonTmuxServerInfo()
? { tmuxServerMode: daemonTmuxServerInfo()!.mode } : {}),
resumeCommand: computeResumeCommand(row.runtime, row.resume_token, row.codex_config_profile, codexDaemonOptOuts.get(row.node_id), row.claude_mcp),
// OPR.0.4.0.26: recoveryGuidance is NOT inlined per node in the LIST
// payload. It duplicated ~47KB of templated prose across all nodes and
@@ -7,6 +7,8 @@ import type { PersistedEvent } from "./types.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import type { TranscriptStore } from "./transcript-store.js";
import { startTmuxTranscriptCapture } from "./transcript-capture.js";
import { realpathSync } from "node:fs";
import { resolve } from "node:path";
export type SelfAttachSuccess = {
ok: true;
@@ -35,6 +37,7 @@ export type SelfAttachFailureCode =
| "invalid_member_name"
| "runtime_required"
| "runtime_mismatch"
| "tmux_server_mismatch"
| "address_conflict";
export type SelfAttachResult =
@@ -48,6 +51,7 @@ interface SelfAttachServiceDeps {
sessionRegistry: SessionRegistry;
eventBus: EventBus;
tmuxAdapter?: TmuxAdapter;
tmuxServer?: { socketPath?: string; mode: "inherit" | "isolated" };
transcriptStore?: TranscriptStore;
claudeContextProvisioner?: {
ensureContextCollector(binding: { cwd?: string | null; tmuxSession?: string | null }): void;
@@ -88,6 +92,7 @@ interface TmuxAttachContext {
tmuxSession: string;
tmuxWindow?: string;
tmuxPane?: string;
tmuxSocketPath?: string;
}
export type SelfAttachContext = ExternalCliAttachContext | TmuxAttachContext;
@@ -102,6 +107,7 @@ export class SelfAttachService {
private transcriptStore: TranscriptStore | null;
private claudeContextProvisioner: SelfAttachServiceDeps["claudeContextProvisioner"] | null;
private activityEnv: { url?: string; token?: string };
private tmuxServer: SelfAttachServiceDeps["tmuxServer"];
constructor(deps: SelfAttachServiceDeps) {
if (deps.db !== deps.rigRepo.db) throw new Error("SelfAttachService: rigRepo must share the same db handle");
@@ -114,12 +120,15 @@ export class SelfAttachService {
this.sessionRegistry = deps.sessionRegistry;
this.eventBus = deps.eventBus;
this.tmuxAdapter = deps.tmuxAdapter ?? null;
this.tmuxServer = deps.tmuxServer;
this.transcriptStore = deps.transcriptStore ?? null;
this.activityEnv = deps.activityEnv ?? {};
this.claudeContextProvisioner = deps.claudeContextProvisioner ?? null;
}
async attachToNode(opts: AttachToNodeOptions): Promise<SelfAttachResult> {
const serverFailure = this.checkTmuxServer(opts.context);
if (serverFailure) return serverFailure;
const rig = this.rigRepo.getRig(opts.rigId);
if (!rig) {
return { ok: false, code: "rig_not_found", error: "Target rig not found" };
@@ -156,6 +165,8 @@ export class SelfAttachService {
}
async attachToPod(opts: AttachToPodOptions): Promise<SelfAttachResult> {
const serverFailure = this.checkTmuxServer(opts.context);
if (serverFailure) return serverFailure;
const rig = this.rigRepo.getRig(opts.rigId);
if (!rig) {
return { ok: false, code: "rig_not_found", error: "Target rig not found" };
@@ -209,6 +220,16 @@ export class SelfAttachService {
}
}
private checkTmuxServer(context?: SelfAttachContext): SelfAttachResult | undefined {
const expected = this.tmuxServer?.socketPath;
if (this.tmuxServer?.mode !== "isolated" || !expected || context?.attachmentType !== "tmux") return;
const canonical = (file: string) => { try { return realpathSync(file); } catch { return resolve(file); } };
if (context.tmuxSocketPath && canonical(context.tmuxSocketPath) === canonical(expected)) return;
return { ok: false, code: "tmux_server_mismatch", error: context.tmuxSocketPath
? "This shell uses a different tmux server. Open a shell on the server reported by rig terminal status before attaching it."
: "This instance uses an isolated tmux server. Update the CLI so attach --self can report this shell's socket." };
}
private async attachExistingNode(args: {
rigId: string;
rigName: string;
@@ -67,6 +67,8 @@ export interface ViewDescriptor { kind: "following" | "fixed"; name: string }
export interface ComposedView {
id: string;
/** Explicit daemon server for local viewer creation as well as its attached seats. */
localTmuxServer?: readonly string[];
task?: TaskMembership;
viewDescriptor?: ViewDescriptor;
/** Service-provided preview fingerprint, attached after composition for provider labels. */
@@ -43,7 +43,8 @@ import { composeView, type ViewMemberInput } from "./view-composer.js";
import { createHash } from "node:crypto";
import { isAbsolute } from "node:path";
import { buildGridRoot } from "./herdr-adapter.js";
import { daemonTmuxServerArgs } from "../../adapters/tmux-server.js";
import { daemonTmuxServerArgs, daemonTmuxServerInfo } from "../../adapters/tmux-server.js";
import type { TmuxServerSelection } from "../tmux-server-selection.js";
// deriveViewMembers is a VALUE exported by the views store (not the composer).
import { deriveViewMembers } from "./terminal-views-store.js";
import type {
@@ -84,6 +85,7 @@ export interface OpenViewRequest {
export interface TerminalPreview {
provider: string;
session?: string;
tmux?: TmuxServerSelection;
view: string;
planId: string;
composed: ComposedView;
@@ -114,6 +116,7 @@ export interface ProviderStatusReport {
export interface TerminalStatusResult {
following?: ReturnType<TerminalFollowing["list"]>;
providers: ProviderStatusReport[];
tmux?: TmuxServerSelection;
session?: string;
error?: string;
code?: string;
@@ -324,7 +327,7 @@ export class TerminalService {
if (!view) return { code: "view_required", error: "a view argument is required" };
const resolved = await this.resolveView(view);
if ("code" in resolved) return resolved;
const composed = composeView(resolved.id, await this.refineLiveness(resolved.members), { resolveHost: (id) => this.deps.resolveHost(id), panesPerPage: resolved.panesPerPage ?? panesPerPage, localTmux: await this.resolveLocalTmux(), localTmuxServer: daemonTmuxServerArgs() });
const composed = composeView(resolved.id, await this.refineLiveness(resolved.members), { resolveHost: (id) => this.deps.resolveHost(id), panesPerPage: resolved.panesPerPage ?? panesPerPage, localTmux: await this.resolveLocalTmux(), localTmuxServer: daemonTmuxServerArgs(), localTmuxServerMode: daemonTmuxServerInfo()?.mode });
const saved = view.startsWith("saved:") ? this.deps.viewsStore.get(view.slice(6))
: !/^(qitem|mission|slice|pod|seat|rig):/.test(view) ? this.deps.viewsStore.get(view) : null;
composed.viewDescriptor = { kind: saved && saved.id === resolved.id ? "fixed" : "following",
@@ -363,7 +366,8 @@ export class TerminalService {
const composed = await this.resolveComposed(req.view, provider.panesPerPage, req.viewportColumns);
if ("code" in composed) return errorResult(providerName, composed.code, composed.error);
await this.viewerMappings(provider, composed);
return { provider: providerName, ...(session !== undefined ? { session } : {}), view: req.view, composed, viewDescriptor: composed.viewDescriptor, ...(composed.task ? { task: composed.task } : {}),
const tmux = daemonTmuxServerInfo();
return { provider: providerName, ...(session !== undefined ? { session } : {}), ...(tmux ? { tmux } : {}), view: req.view, composed, viewDescriptor: composed.viewDescriptor, ...(composed.task ? { task: composed.task } : {}),
capabilities: { ensureViewer: !!composed.task && !!provider.ensureViewer },
grids: composed.pages.map(page => buildGridRoot(page, composed.columns)), planId: this.planId(providerName, composed, session), status: await provider.status() };
}
@@ -430,7 +434,7 @@ export class TerminalService {
const inventory = await this.deps.listRigSeatsBatch?.(result.rigs);
for (const entry of entries) {
const rows = entry.kind === "derived" ? inventory?.get(entry.name) : undefined;
const plan = rows ? composeView(entry.view, await this.refineLiveness(deriveViewMembers(rows, { readOnly: false })), { resolveHost: id => this.deps.resolveHost(id), panesPerPage: this.deps.resolveProvider(DEFAULT_PROVIDER)?.panesPerPage, localTmux: await this.resolveLocalTmux(), localTmuxServer: daemonTmuxServerArgs() })
const plan = rows ? composeView(entry.view, await this.refineLiveness(deriveViewMembers(rows, { readOnly: false })), { resolveHost: id => this.deps.resolveHost(id), panesPerPage: this.deps.resolveProvider(DEFAULT_PROVIDER)?.panesPerPage, localTmux: await this.resolveLocalTmux(), localTmuxServer: daemonTmuxServerArgs(), localTmuxServerMode: daemonTmuxServerInfo()?.mode })
: await this.resolveComposed(entry.view, this.deps.resolveProvider(DEFAULT_PROVIDER)?.panesPerPage);
if ("code" in plan) continue;
result.catalog.push({ ...entry, members: [...plan.opened, ...plan.absent, ...plan.degraded].map((m) => m.seat), ready: plan.opened.length, absent: plan.absent.length, degraded: plan.degraded.length, pages: plan.pages.length });
@@ -441,10 +445,11 @@ export class TerminalService {
/** Provider availability + liveness (doctor). Unknown named provider → empty report for it. */
async status(providerName?: string, session?: string): Promise<TerminalStatusResult> {
const tmux = daemonTmuxServerInfo();
if (session !== undefined) {
const selected = this.selectProvider({ provider: providerName, session });
if ("ok" in selected) return { providers: [], error: selected.error, code: selected.code };
return { following: this.following?.list() ?? [], session: selected.session, providers: [{ name: selected.name,
return { following: this.following?.list() ?? [], session: selected.session, ...(tmux ? { tmux } : {}), providers: [{ name: selected.name,
status: await selected.provider.status(), liveness: await selected.provider.liveness() }] };
}
const names: string[] = providerName ? [providerName] : ["herdr", "cmux", "rex"];
@@ -460,7 +465,7 @@ export class TerminalService {
}
return { name, status: await p.status(), liveness: await p.liveness() };
}));
return { providers, following: this.following?.list() ?? [] };
return { providers, following: this.following?.list() ?? [], ...(tmux ? { tmux } : {}) };
}
/** Resolve a view argument into composer-ready members (precedence in the file header). */
@@ -9,7 +9,8 @@
// (tmux = the daemon-resolved absolute executable)
// view-only / cross-rig (read-only)
// → the same reattach command with read-only `-r`
// ssh host → `ssh <dest> <reattach command>`
// ssh host → `ssh -t <dest> <reattach command>` using remote tmux in inherit mode;
// isolated views use remote `rig terminal attach` to resolve its server
// http host → NO pane; honest-degrade { seat, host, reason }
// dead / no session → absent[] (named, never silently dropped)
//
@@ -70,11 +71,14 @@ export interface ComposeContext {
*/
localTmux?: string;
/**
* The daemon's tmux server flags (`-L <name>` or `-S <path>`), for LOCAL panes only. The daemon's
* sessions live on the server its inherited $TMUX names, which a new terminal doesn't reach by
* default. Absent or empty → no flags, unchanged. Never put into an ssh remote command.
* The daemon's effective tmux server flags (`-L <name>` or `-S <path>`), for LOCAL panes only.
* An inherited or dedicated socket may differ from the attaching terminal's server.
* Absent or empty → no flags, unchanged. Remote panes resolve their server on that host.
*/
localTmuxServer?: readonly string[];
/** Effective mode of the viewing instance. Inherit keeps plain remote tmux;
* isolated opts SSH tiles into remote instance-aware attachment. */
localTmuxServerMode?: "inherit" | "isolated";
}
/** POSIX single-quote a string so session names / targets are shell-inert in the composed command. */
@@ -167,13 +171,21 @@ export function composeView(
});
continue;
}
const remoteCommand = tileAttachArgv("tmux", [], m.tmuxSession, m.readOnly).map(shellQuote).join(" ");
// Inherit keeps remote tmux (no remote rig dependency), including main's
// identity-pinned replacement handling. Isolated views resolve the remote
// instance; local socket flags never cross SSH and lookup failure cannot fall back.
const isolated = ctx.localTmuxServerMode === "isolated";
const recoveryCommand = isolated
? `env OPENRIG_HOST_SELECTED=local rig terminal attach${m.readOnly ? " --read-only" : ""} -- ${shellQuote(m.tmuxSession)}`
: `tmux attach ${attachFlag}-t ${shellQuote(m.tmuxSession)}`;
const remoteCommand = isolated ? recoveryCommand
: tileAttachArgv("tmux", [], m.tmuxSession, m.readOnly).map(shellQuote).join(" ");
opened.push({
seat: m.seat,
...(m.runtime ? { runtime: m.runtime } : {}),
label: m.label,
paneCommand: `ssh ${shellQuote(dest)} ${shellQuote(remoteCommand)}`,
recoveryCommand: `ssh ${shellQuote(dest)} ${shellQuote(`tmux attach ${attachFlag}-t ${shellQuote(m.tmuxSession)}`)}`,
paneCommand: `ssh -t ${shellQuote(dest)} ${shellQuote(remoteCommand)}`,
recoveryCommand: `ssh -t ${shellQuote(dest)} ${shellQuote(recoveryCommand)}`,
readOnly: m.readOnly,
});
continue;
@@ -208,5 +220,6 @@ export function composeView(
});
}
return { id, opened, absent, degraded, pages: chunkPanes(opened, ctx.panesPerPage ?? PANES_PER_PAGE) };
return { id, opened, absent, degraded, pages: chunkPanes(opened, ctx.panesPerPage ?? PANES_PER_PAGE),
...(ctx.localTmuxServer?.length ? { localTmuxServer: [...ctx.localTmuxServer] } : {}) };
}
@@ -0,0 +1,245 @@
import fs from "node:fs";
import path from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { execArgvCommand } from "../adapters/tmux-exec.js";
import { tmuxServerArgs } from "../adapters/tmux-server.js";
export type TmuxServerMode = "inherit" | "isolated";
export interface TmuxServerSelection {
requestedMode: TmuxServerMode;
mode: TmuxServerMode;
socketPath?: string;
/** Attachment metadata: inherited non-default servers still need their flags. */
serverArgs: string[];
/** Native commands inherit normally unless isolation requires a fixed server. */
executionArgs: string[];
migrationPending: boolean;
blockingSessions: string[];
note?: string;
}
interface ServerRecord {
version: 1;
home: string;
mode: TmuxServerMode;
pinned: boolean;
socketPath: string;
isolatedSocketPath?: string;
}
// Unlike the old "1" marker (also written by inherit), this denotes an active
// explicit selection. The record remains the authority for a completed opt-out.
const PINNED_MARKER = "openrig-tmux-pinned-v1\n";
export type ServerInspection =
| { state: "available"; sessions: Array<{ name: string; home?: string }> }
| { state: "absent" }
| { state: "unavailable" };
interface SelectionOptions {
home: string;
mode: TmuxServerMode;
registeredSessions: readonly string[];
env?: NodeJS.ProcessEnv;
/** In-memory compositions do not leave a durable instance record. */
persist?: boolean;
platform?: NodeJS.Platform;
uid?: number;
inspect?: (socket: string) => Promise<ServerInspection>;
}
function canonical(file: string): string {
try { return fs.realpathSync(file); }
catch { return path.resolve(file); }
}
/** TMUX appends two numeric fields; a socket path itself may contain commas. */
export function inheritedTmuxSocket(env: NodeJS.ProcessEnv, uid: number): string {
const inherited = env.TMUX?.replace(/,\d+,\d+$/, "");
if (inherited && path.isAbsolute(inherited)) return inherited;
return path.join(canonical(env.TMUX_TMPDIR?.trim() || "/tmp"), `tmux-${uid}`, "default");
}
/** Listing the old server is observation only; it never starts or stops a server. */
export async function inspectTmuxServer(socket: string): Promise<ServerInspection> {
try {
// Outside tmux, a C locale otherwise sanitizes the tab to '_', losing both
// the names and their ownership. This is a machine-readable protocol.
const output = await execArgvCommand(["tmux", "-u", "-S", socket, "list-sessions", "-F", "#{session_name}\t#{OPENRIG_HOME}"]);
const lines = output.split(/\r?\n/).filter(Boolean);
if (lines.some(line => !line.includes("\t"))) return { state: "unavailable" };
return { state: "available", sessions: lines.map(line => {
const [name, ...home] = line.split("\t");
const owner = home.join("\t");
return { name: name!, ...(owner ? { home: owner } : {}) };
}) };
} catch (error) {
const message = error instanceof Error ? error.message : "";
if (!/permission denied|operation not permitted|EACCES|EPERM/i.test(message)
&& (/no server running/i.test(message)
|| (/error connecting to/i.test(message) && /No such file or directory|Connection refused/i.test(message)))) {
return { state: "absent" };
}
return { state: "unavailable" };
}
}
function readRecord(file: string, home: string): ServerRecord | null {
let value: unknown;
try { value = JSON.parse(fs.readFileSync(file, "utf8")); }
catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw new Error(`Cannot read the tmux server record ${file}: ${error instanceof Error ? error.message : String(error)}`);
}
const record = value as Partial<ServerRecord> | null;
const pinned = record?.pinned === true || record?.mode === "isolated";
if (!record || record.version !== 1 || record.home !== home
|| !["inherit", "isolated"].includes(record.mode ?? "")
|| (record.pinned !== undefined && typeof record.pinned !== "boolean")
|| (record.mode === "isolated" && record.pinned === false)
|| typeof record.socketPath !== "string" || !path.isAbsolute(record.socketPath) || record.socketPath.includes("\0")
|| (record.isolatedSocketPath !== undefined
&& (typeof record.isolatedSocketPath !== "string" || !path.isAbsolute(record.isolatedSocketPath) || record.isolatedSocketPath.includes("\0")))) {
throw Object.assign(new Error(`Invalid tmux server record ${file}; inspect it before changing this instance's server.`), { pinned });
}
// Legacy isolated records were pinned; legacy inherit records were advisory.
return { ...record, pinned } as ServerRecord;
}
function readPinMarker(home: string): { active: boolean; legacy?: boolean; error?: Error } {
const file = path.join(home, "tmux-server.initialized");
try {
const value = fs.readFileSync(file, "utf8").trim();
return { active: value === PINNED_MARKER.trim(), legacy: value === "1" };
}
catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { active: false };
return { active: false, error: new Error(`Cannot read the tmux pin marker ${file}: ${error instanceof Error ? error.message : String(error)}`) };
}
}
function atomicWrite(file: string, content: string): void {
const temporary = `${file}.${randomUUID()}.tmp`;
try {
fs.writeFileSync(temporary, content, { mode: 0o600, flag: "wx" });
fs.renameSync(temporary, file);
} catch (error) {
try { fs.unlinkSync(temporary); }
catch { /* Best effort: preserve the original persistence error. */ }
throw error;
}
}
function writeRecord(file: string, record: ServerRecord): void {
const marker = path.join(record.home, "tmux-server.initialized");
// Establish pin evidence before the record or any session operation. A completed
// opt-out commits pinned:false first, then removes that evidence. The caller
// must not start on another server if either part of a pin transition fails.
if (record.pinned) atomicWrite(marker, PINNED_MARKER);
atomicWrite(file, JSON.stringify(record, null, 2) + "\n");
if (!record.pinned) {
try { fs.unlinkSync(marker); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
}
}
/**
* Choose once, before reconciliation or launch. A different requested server is
* deferred while registered sessions survive on the previous one, or while that
* server cannot be inspected. No pane is moved, restarted or killed here.
*/
export async function selectTmuxServer(options: SelectionOptions): Promise<TmuxServerSelection> {
const env = options.env ?? process.env;
const uid = options.uid ?? process.getuid?.();
if ((options.platform ?? process.platform) === "win32" || uid === undefined) {
return {
requestedMode: options.mode, mode: "inherit", serverArgs: [], executionArgs: [],
migrationPending: options.mode === "isolated", blockingSessions: [],
...(options.mode === "isolated" ? { note: "Dedicated tmux servers require POSIX tmux; psmux keeps its current server." } : {}),
};
}
// A new home beneath a symlinked parent must exist before hashing its real path.
// Normal inheritance still has no writable-home prerequisite.
if (options.mode === "isolated") fs.mkdirSync(options.home, { recursive: true });
const home = canonical(options.home);
const stateFile = path.join(home, "tmux-server.json");
const marker: ReturnType<typeof readPinMarker> = options.persist === false ? { active: false } : readPinMarker(home);
let previous: ServerRecord | null = null;
let readableRecord = true;
if (options.persist !== false) {
try { previous = readRecord(stateFile, home); }
catch (error) {
if (options.mode === "isolated" || marker.active || marker.error || (error as { pinned?: boolean }).pinned) throw error;
// Default inheritance never depended on this file. Do not turn an old
// advisory record into a boot refusal, or overwrite it while unreadable.
readableRecord = false;
}
if (!previous && (marker.active || (marker.legacy && options.mode === "isolated"))) {
throw new Error(`Missing tmux server record ${stateFile} after server selection; restore it before starting this instance.`);
}
if (marker.error && (!previous || previous.pinned || options.mode === "isolated")) throw marker.error;
}
const inherited = inheritedTmuxSocket(env, uid);
const namespace = "openrig-" + createHash("sha256").update(home).digest("hex").slice(0, 20);
const isolated = previous?.isolatedSocketPath
?? path.join(canonical(env.TMUX_TMPDIR?.trim() || "/tmp"), `tmux-${uid}`, namespace);
const desired = options.mode === "isolated" ? isolated : inherited;
const oldSocket = previous?.socketPath ?? inherited;
let mode = options.mode;
let socketPath = desired;
let migrationPending = false;
let blockingSessions: string[] = [];
let note: string | undefined;
const isolation = options.mode === "isolated" || previous?.pinned;
if (isolation && oldSocket !== desired && options.registeredSessions.length > 0) {
const observation = await (options.inspect ?? inspectTmuxServer)(oldSocket);
const registered = new Set(options.registeredSessions);
if (observation.state === "available") {
// Legacy sessions without an owner marker remain conservative blockers.
// An explicit other home can reuse our canonical names without owning our seats.
blockingSessions = observation.sessions
.filter(session => registered.has(session.name) && (!session.home || canonical(session.home) === home))
.map(session => session.name);
}
if (observation.state === "unavailable" || blockingSessions.length > 0) {
socketPath = oldSocket;
mode = previous?.mode ?? "inherit";
migrationPending = true;
note = observation.state === "unavailable"
? "The previous tmux server could not be inspected; it remains selected. Retry the daemon restart when it is reachable."
: "Registered sessions still use the previous tmux server. Stop those sessions deliberately, then restart the daemon to apply the requested server.";
}
}
const executionArgs = mode === "isolated" || migrationPending ? ["-S", socketPath] : [];
if (executionArgs.length > 0) {
// -S does not create tmux's usual per-user directory. The inherited path
// keeps tmux's own directory and length handling, as it did before selection.
const socketLimit = (options.platform ?? process.platform) === "darwin" ? 104 : 108;
if (Buffer.byteLength(socketPath) >= socketLimit) {
throw new Error("The selected tmux socket path is too long. Use a shorter TMUX_TMPDIR before first enabling isolation.");
}
fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 });
}
if (options.persist !== false && readableRecord) {
const pinned = executionArgs.length > 0;
const strict = pinned || previous?.pinned || marker.active;
try {
fs.mkdirSync(home, { recursive: true });
writeRecord(stateFile, { version: 1, home, mode, pinned, socketPath,
...(mode === "isolated" ? { isolatedSocketPath: socketPath }
: previous?.isolatedSocketPath ? { isolatedSocketPath: previous.isolatedSocketPath } : {}) });
} catch (error) {
if (strict) throw error;
// Inherited selection is advisory; an unwritable home must not change it.
}
}
return {
requestedMode: options.mode, mode, socketPath, executionArgs,
serverArgs: executionArgs.length > 0 ? [...executionArgs] : tmuxServerArgs(env, uid),
migrationPending, blockingSessions, ...(note ? { note } : {}),
};
}
+4
View File
@@ -751,6 +751,10 @@ export interface NodeInventoryEntry {
previousOccupant: string | null;
handoverAt: string | null;
tmuxAttachCommand: string | null;
/** Structured server identity for clients that attach without a shell. */
tmuxServerArgs?: string[];
/** Effective mode; inherited non-default sockets also have server arguments. */
tmuxServerMode?: "inherit" | "isolated";
resumeCommand: string | null;
recoveryGuidance: NodeRecoveryGuidance | null;
latestError: string | null;
@@ -33,7 +33,7 @@ const PATHS = new Set(["db.path", "transcripts.path", "workspace.root", "workspa
"topology.root", "context.root", "skills.root", "policies.claude_compaction.message_file_path", "policies.claude_compaction.preparation_root"]);
const IDENTITIES = new Set(["daemon.host", "host.name", "host.selected", "context.system_world",
"ui.timezone", "agents.advisor_session", "agents.operator_session", "workspace.operator_seat_name",
"workflow.exception_routing", "policies.idle_gate_qitem.auto_register"]);
"workflow.exception_routing", "policies.idle_gate_qitem.auto_register", "terminal.tmux_server"]);
const INSTRUCTIONS = new Set(["policies.claude_compaction.pre_compact_instruction",
"policies.claude_compaction.compact_instruction", "policies.claude_compaction.message_inline",
"policies.claude_compaction.post_restore_audit_instruction"]);
@@ -90,6 +90,7 @@ function sourceFile(file: string): { state: State; bytes: string | null } {
catch (error) { return { state: (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "unavailable", bytes: null }; }
}
function application(key: string): string {
if (key === "terminal.tmux_server") return "Selected at daemon restart; surviving sessions keep their previous server until deliberately stopped. See terminal status for the effective choice.";
if (key === "ui.terminal.max_live_terminals") return "Legacy web client; unset resolver value, consumer fallback 2. No TUI limit implied.";
if (key === "workflow.exception_routing") return "Unset leaves routing to workflow/class/host precedence; running route unverified.";
if (key === "ui.timezone") return "Daemon-instance value; TUI display timezone is client-local and selected at launch.";
@@ -186,6 +186,7 @@ export const SETTINGS_VALID_KEYS = [
// twins). Consumed by NodeLauncher at session-create only; a flip is
// future-launches-only and never retroactive (BR-1 never-retro).
"terminal.status_bar",
"terminal.tmux_server",
// The managed ~/.tmux.conf block's history-limit; written by `rig setup` (CLI), twin of the CLI key.
"terminal.history_limit",
"terminal.inherit_tmux_environment",
@@ -317,6 +318,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
"snapshots.periodic.retention_keep": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_RETENTION_KEEP" },
// OPR.0.4.6.02 S1 — net-new key; OPENRIG_* primary only (no RIGGED_* legacy).
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
"terminal.tmux_server": { primary: "OPENRIG_TMUX_SERVER" },
"terminal.history_limit": { primary: "OPENRIG_TERMINAL_HISTORY_LIMIT" },
"terminal.inherit_tmux_environment": { primary: "OPENRIG_TERMINAL_INHERIT_TMUX_ENVIRONMENT" },
"output.compact": { primary: "OPENRIG_OUTPUT_COMPACT" },
@@ -414,6 +416,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
"snapshots.periodic.interval_seconds": ["snapshots", "periodic", "intervalSeconds"],
"snapshots.periodic.retention_keep": ["snapshots", "periodic", "retentionKeep"],
"terminal.status_bar": ["terminal", "statusBar"],
"terminal.tmux_server": ["terminal", "tmuxServer"],
"terminal.history_limit": ["terminal", "historyLimit"],
"terminal.inherit_tmux_environment": ["terminal", "inheritTmuxEnvironment"],
"output.compact": ["output", "compact"],
@@ -721,6 +724,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
// (herdr's pane label already carries identity; the inner tmux status
// is redundant chrome). Operator flip is future-launches-only.
case "terminal.status_bar": return false;
case "terminal.tmux_server": return "inherit";
case "terminal.history_limit": return 10000;
case "terminal.inherit_tmux_environment": return false;
case "output.compact": return false;
@@ -807,6 +811,11 @@ function percentageConstraint(key: string) {
}
const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: string | number | boolean) => void>> = {
"terminal.tmux_server": (raw) => {
if (raw !== "inherit" && raw !== "isolated") {
throw new Error(`Invalid value for terminal.tmux_server: must be "inherit" or "isolated", got "${raw}"`);
}
},
"daemon.db_conflict": (raw) => {
if (raw !== "warn" && raw !== "stop") throw new Error("daemon.db_conflict must be warn or stop");
},
+3
View File
@@ -507,6 +507,7 @@ rigsRoutes.post("/:id/attach-self", async (c) => {
const tmuxSession = typeof body["tmuxSession"] === "string" ? body["tmuxSession"].trim() : "";
const tmuxWindow = typeof body["tmuxWindow"] === "string" ? body["tmuxWindow"].trim() : "";
const tmuxPane = typeof body["tmuxPane"] === "string" ? body["tmuxPane"].trim() : "";
const tmuxSocketPath = typeof body["tmuxSocketPath"] === "string" ? body["tmuxSocketPath"] : undefined;
const hasNodeTarget = logicalId.length > 0;
const hasPodFields = podNamespace.length > 0 || memberName.length > 0;
@@ -533,6 +534,7 @@ rigsRoutes.post("/:id/attach-self", async (c) => {
tmuxSession,
tmuxWindow: tmuxWindow || undefined,
tmuxPane: tmuxPane || undefined,
tmuxSocketPath,
}
: undefined;
@@ -556,6 +558,7 @@ rigsRoutes.post("/:id/attach-self", async (c) => {
case "duplicate_logical_id":
case "invalid_member_name":
case "runtime_mismatch":
case "tmux_server_mismatch":
return c.json(result, 409);
default:
return c.json(result, 500);
+23 -5
View File
@@ -42,6 +42,7 @@ import { NodeLauncher } from "./domain/node-launcher.js";
import { TmuxOptionDefaultsApplier } from "./domain/tmux-option-defaults.js";
import { TmuxAdapter } from "./adapters/tmux.js";
import { setDaemonTmuxServer, tmuxServerArgs } from "./adapters/tmux-server.js";
import { selectTmuxServer, type TmuxServerMode } from "./domain/tmux-server-selection.js";
import { CmuxAdapter } from "./adapters/cmux.js";
import { execArgvCommand, execCommand } from "./adapters/tmux-exec.js";
import { tmuxControlExec } from "./adapters/tmux-control.js";
@@ -525,17 +526,33 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
watchdogAutoRegistration.assertLiveSeatCoverage();
const watchdogHistoryLogInstance = new WatchdogHistoryLog(db);
// Select before reconciliation: a surviving old server must stay reachable
// while a requested move to another server waits for those seats to stop.
const tmuxSelection = opts?.tmuxExec || opts?.argvExec ? undefined : await selectTmuxServer({
home: OPENRIG_HOME,
mode: new ContextPackSettingsStore().resolveOne("terminal.tmux_server").value as TmuxServerMode,
persist: dbPath !== ":memory:",
registeredSessions: (db.prepare(`
SELECT DISTINCT tmux_session AS name FROM bindings
WHERE COALESCE(attachment_type, 'tmux') = 'tmux' AND tmux_session IS NOT NULL
UNION SELECT s.session_name AS name FROM sessions s
WHERE s.id = (SELECT latest.id FROM sessions latest WHERE latest.node_id = s.node_id
ORDER BY datetime(latest.created_at) DESC, latest.rowid DESC LIMIT 1)
AND s.status <> 'exited'
`).all() as Array<{ name: string }>).map(row => row.name).filter(Boolean),
});
if (tmuxSelection?.note) console.warn(`[tmux] ${tmuxSelection.note}`);
// An injected string exec keeps the shell path, so its fake sees every command. OPENRIG_TMUX_SHELL=1 keeps it too.
const spawnedArgvExec = opts?.argvExec
?? (opts?.tmuxExec || (process.env.OPENRIG_TMUX_SHELL === "1" && process.platform !== "win32") ? undefined : execArgvCommand);
// OPENRIG_TMUX_CONTROL=1 drives the real tmux through one control-mode client (tmux-control.ts); by default a client
// is spawned per command. An injected exec is the test's own, and psmux has no control mode.
const argvExec = spawnedArgvExec === execArgvCommand && process.platform !== "win32" && process.env.OPENRIG_TMUX_CONTROL === "1"
? tmuxControlExec(execArgvCommand) : spawnedArgvExec;
const tmuxAdapter = new TmuxAdapter(opts?.tmuxExec ?? execCommand, undefined, argvExec);
// The real tmux runs with this process's environment, so its server is the one $TMUX names.
// An injected exec has no server of its own: printed attach commands stay in the default form.
setDaemonTmuxServer(opts?.tmuxExec ? [] : tmuxServerArgs(process.env));
? tmuxControlExec(execArgvCommand, { serverArgs: tmuxSelection?.executionArgs }) : spawnedArgvExec;
const tmuxAdapter = new TmuxAdapter(opts?.tmuxExec ?? execCommand, undefined, argvExec, tmuxSelection?.executionArgs);
// Inherit keeps the native execution environment; attachment metadata still names
// its server for a new terminal. Isolated execution and attachment share a fixed socket.
setDaemonTmuxServer(tmuxSelection?.serverArgs ?? (opts?.tmuxExec ? [] : tmuxServerArgs(process.env)), tmuxSelection);
const deliveryGuard = new SeatDeliveryGuard(db, target => resolveGuardTarget(db, target));
deliveryGuard.recoverActivation();
tmuxAdapter.deliveryGuard = deliveryGuard;
@@ -1370,6 +1387,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
});
const selfAttachService = new SelfAttachService({
db, rigRepo, podRepo, sessionRegistry, eventBus, tmuxAdapter, transcriptStore,
tmuxServer: tmuxSelection,
claudeContextProvisioner: claudeAdapter,
// OPR.0.4.3.28 B3 — echo the resolved activity url+token into the self-attach
// response env so the caller's shell can produce activity signal.
+13 -4
View File
@@ -319,16 +319,21 @@ export function prepareHermeticEnv(opts: PrepareHermeticEnvOptions = {}): Hermet
// operator's server. Refuse before any filesystem effect, like the others.
assertNoAmbientTmux(baseEnv);
const root = mkdtempSync(join(tmpdir(), "openrig-scenario-"));
// Leave room for the per-home socket, including macOS's /private prefix,
// while keeping every socket within the caller's isolated TMPDIR.
const root = mkdtempSync(join(tmpdir(), "o"));
const home = join(root, "home");
const openrigHome = join(root, "openrig-home");
const stateDir = join(root, "state");
// Keep both compatibility and explicit socket paths below sun_path's cap.
const tmuxTmpDir = join(root, "tx");
const tmuxSocketPath = join(root, "tmux.sock");
const tmuxTmpDir = root;
// Match native default resolution as well as the wrapper. The daemon now
// passes its selected -S explicitly, which takes precedence over the wrapper.
const tmuxServerDir = join(tmuxTmpDir, `tmux-${process.getuid?.() ?? 0}`);
const tmuxSocketPath = join(tmuxServerDir, "default");
const tmuxWrapperDir = join(root, "bin");
const tmuxWrapperPath = join(tmuxWrapperDir, "tmux");
for (const dir of [home, openrigHome, stateDir, tmuxTmpDir, tmuxWrapperDir]) {
for (const dir of [home, openrigHome, stateDir, tmuxTmpDir, tmuxServerDir, tmuxWrapperDir]) {
mkdirSync(dir, { recursive: true });
}
// codex and claude resolve to stubs inside the scaffold, so a scenario daemon never runs the host's
@@ -353,6 +358,10 @@ exec tmux -S ${shellQuote(tmuxSocketPath)} "$@"
const env: Record<string, string | undefined> = { ...baseEnv };
for (const name of DAEMON_TARGET_ENV_VARS) delete env[name];
for (const name of CREDENTIAL_ENV_VARS) delete env[name];
// The fixture owns one explicit socket; an ambient mode must not select an
// additional server that ordinary scaffold cleanup would leave running.
delete env.OPENRIG_TMUX_SERVER;
delete env.RIGGED_TMUX_SERVER;
// Inherited provider roots belong to the runner; explicit ones are test inputs.
if (opts.baseEnv === undefined) {
for (const name of ["CODEX_HOME", "CLAUDE_CONFIG_DIR"]) delete env[name];
@@ -198,6 +198,6 @@ describe("the composer gives a local tile the same attach as an argv", () => {
expect(v.opened[index]!.paneCommand).toBe(argv.map(shellQuote).join(" "));
}
expect(v.opened[2]!.attachArgv).toBeUndefined();
expect(v.opened[2]!.paneCommand).toContain("ssh '[email protected]'");
expect(v.opened[2]!.paneCommand).toContain("ssh -t '[email protected]'");
});
});
+4 -1
View File
@@ -73,10 +73,13 @@ describe("D5 p2 — an inherited TMUX_TMPDIR is ABSENT from the child and REPLAC
it("proves both halves independently", () => {
const inherited = mkdtempSync(join(tmpdir(), "ambient-tmux-"));
try {
const s = prepareHermeticEnv({ baseEnv: { ...cleanBase(), TMUX_TMPDIR: inherited } });
const s = prepareHermeticEnv({ baseEnv: { ...cleanBase(), TMUX_TMPDIR: inherited,
OPENRIG_TMUX_SERVER: "isolated", RIGGED_TMUX_SERVER: "isolated" } });
scaffolds.push(s);
// (a) the inherited value is gone
expect(s.env.TMUX_TMPDIR).not.toBe(inherited);
expect(s.env.OPENRIG_TMUX_SERVER).toBeUndefined();
expect(s.env.RIGGED_TMUX_SERVER).toBeUndefined();
// (b) it is replaced by a scaffold-OWNED, existing directory
expect(s.env.TMUX_TMPDIR).toBe(s.tmuxTmpDir);
expect(s.tmuxTmpDir.startsWith(s.root)).toBe(true);
@@ -55,6 +55,69 @@ describe("SelfAttachService", () => {
db.close();
});
it.each([undefined, "/another/server"])("rejects a foreign or unreported socket before creating any binding: %s", async socketPath => {
const service = new SelfAttachService({ db, rigRepo, podRepo, sessionRegistry, eventBus,
tmuxServer: { socketPath: "/selected/server", mode: "isolated" } });
const rig = rigRepo.createRig("socket-fixture");
const pod = podRepo.createPod(rig.id, "dev", "Dev");
const node = rigRepo.addNode(rig.id, "dev.existing", { runtime: "stub", podId: pod.id });
const context = { attachmentType: "tmux" as const, tmuxSession: "same-name", tmuxPane: "%0", tmuxSocketPath: socketPath };
expect(await service.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context }))
.toMatchObject({ ok: false, code: "tmux_server_mismatch" });
expect(await service.attachToPod({ rigId: rig.id, podNamespace: "dev", memberName: "new", runtime: "stub", context }))
.toMatchObject({ ok: false, code: "tmux_server_mismatch" });
expect(sessionRegistry.getBindingForNode(node.id)).toBeNull();
expect(rigRepo.getRig(rig.id)!.nodes).toHaveLength(1);
expect(sessionRegistry.getSessionsForRig(rig.id)).toHaveLength(0);
});
it("accepts the selected socket and keeps external CLI attachment available", async () => {
const service = new SelfAttachService({ db, rigRepo, podRepo, sessionRegistry, eventBus,
tmuxServer: { socketPath: "/selected/server", mode: "isolated" } });
const rig = rigRepo.createRig("socket-fixture");
rigRepo.addNode(rig.id, "dev.tmux", { runtime: "stub" });
rigRepo.addNode(rig.id, "dev.external", { runtime: "stub" });
expect(await service.attachToNode({ rigId: rig.id, logicalId: "dev.tmux", context: {
attachmentType: "tmux", tmuxSession: "selected-seat", tmuxSocketPath: "/selected/server",
} })).toMatchObject({ ok: true, attachmentType: "tmux" });
expect(await service.attachToNode({ rigId: rig.id, logicalId: "dev.external" }))
.toMatchObject({ ok: true, attachmentType: "external_cli" });
});
it.each([undefined, "/another/server"])("keeps inherited self-attach compatible with a missing or different socket: %s", async socketPath => {
const service = new SelfAttachService({ db, rigRepo, podRepo, sessionRegistry, eventBus,
tmuxServer: { socketPath: "/selected/server", mode: "inherit" } });
const rig = rigRepo.createRig("socket-fixture");
const pod = podRepo.createPod(rig.id, "dev", "Dev");
const node = rigRepo.addNode(rig.id, "dev.existing", { runtime: "stub", podId: pod.id });
const context = { attachmentType: "tmux" as const, tmuxSession: "raw-existing", tmuxSocketPath: socketPath };
expect(await service.attachToNode({ rigId: rig.id, logicalId: node.logicalId, context }))
.toMatchObject({ ok: true, attachmentType: "tmux" });
expect(await service.attachToPod({ rigId: rig.id, podNamespace: "dev", memberName: "new", runtime: "stub",
context: { ...context, tmuxSession: "raw-new" } })).toMatchObject({ ok: true, attachmentType: "tmux" });
expect(sessionRegistry.getBindingForNode(node.id)?.tmuxSession).toBe("raw-existing");
expect(rigRepo.getRig(rig.id)!.nodes).toHaveLength(2);
expect(sessionRegistry.getSessionsForRig(rig.id)).toHaveLength(2);
});
it("retains the registry's address-conflict protection when inherited socket checks are skipped", async () => {
const service = new SelfAttachService({ db, rigRepo, podRepo, sessionRegistry, eventBus,
tmuxServer: { socketPath: "/selected/server", mode: "inherit" } });
const rig = rigRepo.createRig("socket-fixture");
const pod = podRepo.createPod(rig.id, "dev", "Dev");
rigRepo.addNode(rig.id, "dev.first", { runtime: "stub", podId: pod.id });
const second = rigRepo.addNode(rig.id, "dev.second", { runtime: "stub", podId: pod.id });
const context = { attachmentType: "tmux" as const, tmuxSession: "shared-session", tmuxSocketPath: "/another/server" };
expect(await service.attachToNode({ rigId: rig.id, logicalId: "dev.first", context })).toMatchObject({ ok: true });
expect(await service.attachToNode({ rigId: rig.id, logicalId: "dev.second", context }))
.toMatchObject({ ok: false, code: "address_conflict" });
expect(await service.attachToPod({ rigId: rig.id, podNamespace: "dev", memberName: "new", runtime: "stub", context }))
.toMatchObject({ ok: false, code: "address_conflict" });
expect(sessionRegistry.getBindingForNode(second.id)).toBeNull();
expect(rigRepo.getRig(rig.id)!.nodes).toHaveLength(2);
expect(sessionRegistry.getSessionsForRig(rig.id)).toHaveLength(1);
});
it("attachToNode binds the current raw agent as external_cli", async () => {
const rig = rigRepo.createRig("rigged-buildout");
const node = rigRepo.addNode(rig.id, "orch1.lead", {
@@ -94,15 +94,26 @@ describe("view-composer partition vectors", () => {
expect(v.opened[0]!.readOnly).toBe(true);
});
it("ssh host → ssh '<user@target>' tmux attach -t (destination shell-quoted); read-only adds -r", () => {
it("an isolated view resolves the remote daemon's server with a terminal and preserves read-only", () => {
const rw = composeView("v", [member({ seat: "s@r", host: "vm1", tmuxSession: "s@r" })], { ...ctxWith([SSH_HOST]), localTmuxServerMode: "isolated" });
expect(rw.opened[0]!.paneCommand).toBe("ssh -t '[email protected]' 'env OPENRIG_HOST_SELECTED=local rig terminal attach -- '\"'\"'s@r'\"'\"''");
const ro = composeView("v", [member({ seat: "s@r", host: "vm1", readOnly: true })], { ...ctxWith([SSH_HOST]), localTmuxServerMode: "isolated" });
expect(ro.opened[0]!.paneCommand).toBe("ssh -t '[email protected]' 'env OPENRIG_HOST_SELECTED=local rig terminal attach --read-only -- '\"'\"'s@r'\"'\"''");
const nouser = composeView("v", [member({ seat: "s@r", host: "vm2" })], { ...ctxWith([SSH_HOST_NO_USER]), localTmuxServerMode: "isolated" });
expect(nouser.opened[0]!.paneCommand).toBe("ssh -t '10.0.0.9' 'env OPENRIG_HOST_SELECTED=local rig terminal attach -- '\"'\"'s@r'\"'\"''");
});
it("inherited ssh host → ssh -t '<user@target>' tmux attach -t (destination shell-quoted); read-only adds -r", () => {
const rw = composeView("v", [member({ seat: "s@r", host: "vm1", tmuxSession: "s@r" })], ctxWith([SSH_HOST]));
expect(rw.opened[0]!.paneCommand).toBe(`ssh ${shellQuote('[email protected]')} ${shellQuote(tileAttachArgv("tmux", [], "s@r", false).map(shellQuote).join(" "))}`);
expect(rw.opened[0]!.paneCommand).toBe(`ssh -t ${shellQuote('[email protected]')} ${shellQuote(tileAttachArgv("tmux", [], "s@r", false).map(shellQuote).join(" "))}`);
const ro = composeView("v", [member({ seat: "s@r", host: "vm1", readOnly: true })], ctxWith([SSH_HOST]));
expect(ro.opened[0]!.paneCommand).toBe(`ssh ${shellQuote('[email protected]')} ${shellQuote(tileAttachArgv("tmux", [], "s@r", true).map(shellQuote).join(" "))}`);
expect(ro.opened[0]!.paneCommand).toBe(`ssh -t ${shellQuote('[email protected]')} ${shellQuote(tileAttachArgv("tmux", [], "s@r", true).map(shellQuote).join(" "))}`);
const nouser = composeView("v", [member({ seat: "s@r", host: "vm2" })], ctxWith([SSH_HOST_NO_USER]));
expect(nouser.opened[0]!.paneCommand).toBe(`ssh ${shellQuote('10.0.0.9')} ${shellQuote(tileAttachArgv("tmux", [], "s@r", false).map(shellQuote).join(" "))}`);
expect(nouser.opened[0]!.paneCommand).toBe(`ssh -t ${shellQuote('10.0.0.9')} ${shellQuote(tileAttachArgv("tmux", [], "s@r", false).map(shellQuote).join(" "))}`);
});
// Guard G1 — the ssh destination is STRUCTURED registry data injected into a
@@ -113,7 +124,7 @@ describe("view-composer partition vectors", () => {
const v = composeView("v", [member({ seat: "s@r", host: "evil", tmuxSession: "s@r" })], ctxWith([NASTY]));
expect(v.opened).toHaveLength(1);
// single-quoted, embedded quote POSIX-escaped ('\'') — metacharacters are literal.
expect(v.opened[0]!.paneCommand).toBe(`ssh ${shellQuote("u'x@a b; rm -rf /")} ${shellQuote(tileAttachArgv("tmux", [], "s@r", false).map(shellQuote).join(" "))}`);
expect(v.opened[0]!.paneCommand).toBe(`ssh -t ${shellQuote("u'x@a b; rm -rf /")} ${shellQuote(tileAttachArgv("tmux", [], "s@r", false).map(shellQuote).join(" "))}`);
// the dangerous run never becomes its own shell word:
expect(v.opened[0]!.paneCommand).not.toContain("; rm -rf / tmux");
});
@@ -1,35 +1,74 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { composeView } from "../src/domain/terminal/view-composer.js";
it("keeps remote session names intact through both POSIX shell boundaries", () => {
it.each(["inherit", "isolated"] as const)("keeps remote session names intact through both POSIX shell boundaries in %s mode", (mode) => {
const root = mkdtempSync(join(tmpdir(), "openrig-remote-shell-"));
const bin = join(root, "bin");
mkdirSync(bin);
// Simulate ssh's remote-command reconstruction. The local and remote shell
// parsing are real; no SSH server or terminal provider is claimed here.
writeFileSync(join(bin, "ssh"), '#!/bin/sh\nshift\nexec /bin/sh -c "$*"\n', { mode: 0o700 });
writeFileSync(join(bin, "ssh"), '#!/bin/sh\n[ "$1" = -t ] || exit 64\nshift 2\nexec /bin/sh -c "$*"\n', { mode: 0o700 });
// Inherited replacement tracking needs only remote tmux and a POSIX shell, not rig.
writeFileSync(join(bin, "tmux"), '#!/bin/sh\ncase $1 in display-message) printf "%s\\n" "$4" >> "$OPR_ARGS"; target=$4; target=${target#=}; printf "7 session-id:%s\\n" "${target%:}";; show-environment) exit 1;; attach) printf "%s\\n" "$@" >> "$OPR_ARGS";; esac\n', { mode: 0o700 });
if (mode === "isolated") {
writeFileSync(join(bin, "rig"), '#!/bin/sh\nprintf "%s\\n" "$OPENRIG_HOST_SELECTED" "$@" > "$OPR_ARGS"\n', { mode: 0o700 });
}
const argsFile = join(root, "args");
try {
for (const session of ["seat-normal", "seat space", "seat'quote"]) {
for (const session of ["seat-normal", "seat space", "seat'quote", "-seat-option", "seat$(false)"]) {
for (const readOnly of [false, true]) {
const view = composeView("fixture", [{
seat: session, label: session, tmuxSession: session, host: "edge", readOnly, alive: true,
}], { resolveHost: () => ({ id: "edge", transport: "ssh", target: "fixture" }) });
}], { resolveHost: () => ({ id: "edge", transport: "ssh", target: "fixture" }),
localTmuxServer: ["-S", "/local/only/socket"], localTmuxServerMode: mode });
writeFileSync(argsFile, "");
execFileSync("/bin/sh", ["-c", view.opened[0]!.paneCommand], {
env: { PATH: `${bin}:/usr/bin:/bin`, OPR_ARGS: argsFile },
env: { PATH: mode === "inherit" ? bin : `${bin}:/usr/bin:/bin`, OPR_ARGS: argsFile, OPENRIG_HOST_SELECTED: "another-host" },
});
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual([
`=${session}:`, "attach", ...(readOnly ? ["-r"] : ["-f", "ignore-size"]), "-t", "session-id",
]);
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(mode === "isolated"
? ["local", "terminal", "attach", ...(readOnly ? ["--read-only"] : []), "--", session]
: [`=${session}:`, "attach", ...(readOnly ? ["-r"] : ["-f", "ignore-size"]), "-t", "session-id"]);
expect(view.opened[0]!.paneCommand).not.toContain("/local/only/socket");
writeFileSync(argsFile, "");
execFileSync("/bin/sh", ["-c", view.opened[0]!.recoveryCommand!], {
env: { PATH: mode === "inherit" ? bin : `${bin}:/usr/bin:/bin`, OPR_ARGS: argsFile, OPENRIG_HOST_SELECTED: "another-host" },
});
expect(readFileSync(argsFile, "utf8").trimEnd().split("\n")).toEqual(mode === "isolated"
? ["local", "terminal", "attach", ...(readOnly ? ["--read-only"] : []), "--", session]
: ["attach", ...(readOnly ? ["-r"] : ["-f", "ignore-size"]), "-t", session]);
}
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it.each(["missing", "failed"])("does not attach to a namesake when the isolated remote CLI is %s", (failure) => {
const root = mkdtempSync(join(tmpdir(), "openrig-remote-failure-"));
const bin = join(root, "bin");
mkdirSync(bin);
const argsFile = join(root, "tmux-args");
try {
writeFileSync(join(bin, "ssh"), '#!/bin/sh\n[ "$1" = -t ] || exit 64\nshift 2\nexec /bin/sh -c "$*"\n', { mode: 0o700 });
symlinkSync("/usr/bin/env", join(bin, "env"));
writeFileSync(join(bin, "tmux"), '#!/bin/sh\nprintf "%s\\n" "$@" > "$OPR_ARGS"\n', { mode: 0o700 });
if (failure === "failed") {
writeFileSync(join(bin, "rig"), "#!/bin/sh\nexit 69\n", { mode: 0o700 });
}
const view = composeView("fixture", [{
seat: "seat@rig", label: "seat", tmuxSession: "seat@rig", host: "edge", readOnly: false, alive: true,
}], { resolveHost: () => ({ id: "edge", transport: "ssh", target: "fixture" }), localTmuxServerMode: "isolated" });
const result = spawnSync("/bin/sh", ["-c", view.opened[0]!.paneCommand], {
env: { PATH: bin, OPR_ARGS: argsFile }, encoding: "utf8",
});
expect(result.error).toBeUndefined();
expect(result.status).toBe(failure === "missing" ? 127 : 69);
expect(existsSync(argsFile)).toBe(false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
@@ -13,6 +13,7 @@ import { terminalRoutes } from "../src/routes/terminal.js";
import { createHash } from "node:crypto";
import { planHerdrLayout } from "../src/domain/terminal/herdr-adapter.js";
import { TerminalService, type TerminalServiceDeps } from "../src/domain/terminal/terminal-service.js";
import { daemonTmuxServerArgs, daemonTmuxServerInfo, setDaemonTmuxServer } from "../src/adapters/tmux-server.js";
import { RexAdapter } from "../src/domain/terminal/rex-adapter.js";
import { createRexCliTransport, type RexRun } from "../src/domain/terminal/rex-transport.js";
import type {
@@ -64,6 +65,53 @@ const savedView: SavedView = {
],
};
describe("SSH tiles follow the viewing instance's effective server mode", () => {
it.each([
{ requestedMode: "inherit", mode: "inherit" },
{ requestedMode: "isolated", mode: "isolated" },
{ requestedMode: "isolated", mode: "inherit" },
{ requestedMode: "inherit", mode: "isolated" },
] as const)("requested $requestedMode, effective $mode", async ({ requestedMode, mode }) => {
const previousArgs = [...daemonTmuxServerArgs()];
const previousSelection = daemonTmuxServerInfo();
try {
// Inheritance can still name a non-default LOCAL socket; its presence does
// not opt remote tiles into a new command or disclose that local path.
setDaemonTmuxServer(["-S", "/local/only/socket"], {
requestedMode, mode, serverArgs: ["-S", "/local/only/socket"], executionArgs: [],
migrationPending: requestedMode !== mode, blockingSessions: [],
});
const remote: SavedView = { id: "remote", name: "remote", members: [{
seat: "worker@edge", tmuxSession: "worker@edge", host: "edge", readOnly: true,
}] };
const { deps, herdr } = makeDeps({
viewsStore: { get: id => id === remote.id ? remote : null, list: () => [remote] },
resolveHost: id => id === "edge" ? { id, transport: "ssh", target: "fixture" } : null,
});
const svc = new TerminalService(deps);
const preview = await svc.previewView({ view: "remote" }) as { planId: string };
const result = await svc.openView({ view: "remote", expectedPlan: preview.planId });
expect(result.ok).toBe(true);
expect(result.code).toBeUndefined();
const command = herdr.lastView!.opened[0]!.paneCommand;
if (mode === "isolated") {
expect(command).toBe("ssh -t 'fixture' 'env OPENRIG_HOST_SELECTED=local rig terminal attach --read-only -- '\"'\"'worker@edge'\"'\"''");
} else {
// Main's inherited SSH tiles use remote tmux with identity-pinned replacement handling, not remote rig.
expect(command).toMatch(/^ssh -t 'fixture' /);
expect(command).toContain("OPENRIG_NODE_ID");
expect(command).toContain("attach -r -t");
expect(command).toContain("No replacement for $target; reopen the view with rig terminal open.");
expect(command).not.toContain("rig terminal attach");
expect(herdr.lastView!.opened[0]!.recoveryCommand).toBe("ssh -t 'fixture' 'tmux attach -r -t '\"'\"'worker@edge'\"'\"''");
}
expect(command).not.toContain("/local/only/socket");
} finally {
setDaemonTmuxServer(previousArgs, previousSelection);
}
});
});
function makeDeps(overrides: Partial<TerminalServiceDeps> = {}): {
deps: TerminalServiceDeps;
herdr: RecordingProvider;
@@ -87,6 +87,41 @@ describe.skipIf(!supportsControlMode(version))("tmux control mode against a real
expect(spawned.slice(connecting)).toEqual([]);
});
it("keeps two control clients and namesake seats on their selected servers", async () => {
const socket = join(dir, "isolated.sock");
const serverArgs = ["-S", socket];
tmux(...serverArgs, "new-session", "-d", "-s", "seat@rig", "-x", "120", "-y", "30", "printf 'isolated seat\\n'; exec cat");
const isolatedSpawned: string[][] = [];
const isolated = tmuxControlExec((argv) => { isolatedSpawned.push(argv); return execArgvCommand(argv); }, { serverArgs });
try {
const defaultSocket = tmux("display-message", "-p", "-t", "=seat@rig:", "#{socket_path}").trim();
expect(await control(["tmux", "display-message", "-p", "-t", "=seat@rig:", "#{socket_path}"])).toBe(`${defaultSocket}\n`);
expect(await isolated(["tmux", ...serverArgs, "display-message", "-p", "-t", "=seat@rig:", "#{socket_path}"])).toBe(`${socket}\n`);
expect(sessions()).toEqual([controlSessionName(control.owner), "seat@rig"].sort());
expect(tmux(...serverArgs, "list-sessions", "-F", "#{session_name}").trim().split("\n").sort())
.toEqual([controlSessionName(isolated.owner), "seat@rig"].sort());
const connected = isolatedSpawned.length;
const adapter = new TmuxAdapter(async () => "", undefined, isolated, serverArgs);
expect((await adapter.listSessions()).map((session) => session.name)).toEqual(["seat@rig"]);
await vi.waitFor(async () => {
expect(await isolated(["tmux", ...serverArgs, "capture-pane", "-p", "-t", "=seat@rig:", "-S", "-20"])).toContain("isolated seat");
});
expect(isolatedSpawned.slice(connected)).toEqual([]);
// An explicit request for the other server never uses the already-connected isolated client.
const other = ["tmux", "-S", defaultSocket, "display-message", "-p", "-t", "=seat@rig:", "#{socket_path}"];
expect(await isolated(other)).toBe(`${defaultSocket}\n`);
expect(isolatedSpawned.slice(connected)).toEqual([other]);
await isolated.close();
await vi.waitFor(() => expect(tmux(...serverArgs, "list-sessions", "-F", "#{session_name}")).toBe("seat@rig\n"));
expect(sessions()).toEqual([controlSessionName(control.owner), "seat@rig"].sort());
} finally {
await isolated.close();
try { tmux(...serverArgs, "kill-server"); } catch { /* already gone */ }
}
});
it("answers each command with its own output while a user's after-hook prints blocks between them", async () => {
tmux("set-hook", "-g", "after-list-panes", "list-sessions -F hook-output");
await control(["tmux", "has-session", "-t", "=seat@rig"]);
+85 -2
View File
@@ -129,11 +129,12 @@ function tagOf(line: string, before: string, after: string): string {
/** The spawned tmux the control exec falls back to: its own call (the server's version, read before a client starts)
* is answered here; every other argv is recorded and given to `answer`. */
function fakeSpawned(answer: (argv: string[]) => string | Promise<string> = () => "") {
function fakeSpawned(answer: (argv: string[]) => string | Promise<string> = () => "", serverArgs: readonly string[] = []) {
const spawned: string[][] = [];
const own: string[][] = [];
const fallback = async (argv: string[]): Promise<string> => {
if (argv[1] === "list-sessions" && argv[3]?.startsWith("#{version}")) {
const args = argv.slice(1 + serverArgs.length);
if (serverArgs.every((arg, i) => arg === argv[i + 1]) && args[0] === "list-sessions" && args[2]?.startsWith("#{version}")) {
own.push(argv);
return "3.6|\n3.6|\n";
}
@@ -144,6 +145,88 @@ function fakeSpawned(answer: (argv: string[]) => string | Promise<string> = () =
}
describe("tmuxControlExec", () => {
it.each([["-S", "/tmp/rig isolated.sock"], ["-L", "rig-isolated"]])("routes commands on its selected %s server and keeps command-local flags", async (flag, server) => {
const serverArgs = [flag, server];
const client = fakeClient();
const { spawned, own, fallback } = fakeSpawned(() => "spawned\n", serverArgs);
const start = vi.fn((_args: string[]) => client.child);
const exec = tmuxControlExec(fallback, { spawnClient: start, serverArgs, replyTimeoutMs: 1000 });
try {
const read = exec(["tmux", ...serverArgs, "capture-pane", "-p", "-t", "=seat@rig:", "-S", "-20", ";", "has-session", "-t", "=seat@rig"]);
await vi.waitFor(() => expect(client.written).toEqual(["'capture-pane' '-p' '-t' '=seat@rig:' '-S' '-20'"]));
client.answer("selected server");
await vi.waitFor(() => expect(client.written).toEqual([
"'capture-pane' '-p' '-t' '=seat@rig:' '-S' '-20'", "'has-session' '-t' '=seat@rig'",
]));
client.answer();
expect(await read).toBe("selected server\n");
expect(own).toEqual([["tmux", ...serverArgs, "list-sessions", "-F", "#{version}|#{@openrig-control}"]]);
expect(start.mock.calls[0]?.[0]?.slice(0, 3)).toEqual([...serverArgs, "-C"]);
expect(spawned).toEqual([]);
} finally {
await exec.close();
}
});
it("leaves commands for another server or with other global flags unchanged for the spawned client", async () => {
const serverArgs = ["-S", "/tmp/rig-isolated.sock"];
const { spawned, own, fallback } = fakeSpawned(() => "spawned\n", serverArgs);
const start = vi.fn(() => { throw new Error("No control client expected"); });
const exec = tmuxControlExec(fallback, { spawnClient: start, serverArgs });
const listing = ["list-sessions", "-F", "#{session_name}"];
const commands = [
["tmux", "-S", "/tmp/other.sock", ...listing],
["tmux", "-L", "rig-isolated", ...listing],
["tmux", ...listing],
["tmux", ...serverArgs, "-u", ...listing],
["tmux", ...serverArgs, "-S", "/tmp/other.sock", "has-session", "-t", "=s", ";", ...listing],
["tmux", "-S"],
];
for (const argv of commands) expect(await exec(argv)).toBe("spawned\n");
expect(spawned).toEqual(commands);
expect(own).toEqual([]);
expect(start).not.toHaveBeenCalled();
await exec.close();
});
it("filters its own listing rows with server flags, including listings that need a spawned client", async () => {
const serverArgs = ["-S", "/tmp/rig-isolated.sock"];
const client = fakeClient();
const { spawned, fallback } = fakeSpawned(() => "", serverArgs);
const exec = tmuxControlExec(fallback, { spawnClient: () => client.child, serverArgs, owner: "d4e5", replyTimeoutMs: 1000 });
try {
const listing = exec(["tmux", ...serverArgs, "list-sessions", "-F", "#{session_name}"]);
await vi.waitFor(() => expect(client.written).toHaveLength(1));
const tag = tagOf(client.written[0]!, "'list-sessions' '-F' '", "#{session_name}'");
client.answer(`${tag}openrig-control-d4e5`, "seat@rig");
expect(await listing).toBe("seat@rig\n");
expect(await exec(["tmux", ...serverArgs, "list-windows", "-F", "#{window_index}"])).toBe("");
expect(spawned).toHaveLength(1);
expect(spawned[0]!.slice(0, 5)).toEqual(["tmux", ...serverArgs, "list-windows", "-F"]);
tagOf(spawned[0]!.join(" "), `tmux ${serverArgs.join(" ")} list-windows -F `, "#{window_index}");
expect(client.written).toHaveLength(1);
} finally {
await exec.close();
}
});
it("keeps read fallback and a lost write's probe on the selected server without replaying the write", async () => {
const serverArgs = ["-S", "/tmp/rig-isolated.sock"];
const client = fakeClient();
const { spawned, fallback } = fakeSpawned(() => "spawned read\n", serverArgs);
const exec = tmuxControlExec(fallback, { spawnClient: () => client.child, serverArgs, replyTimeoutMs: 1000 });
const readArgv = ["tmux", ...serverArgs, "capture-pane", "-p", "-t", "=seat@rig:"];
const read = exec(readArgv);
await vi.waitFor(() => expect(client.written).toHaveLength(1));
const write = exec(["tmux", ...serverArgs, "send-keys", "-t", "=seat@rig:", "Enter"]).then(() => null, (err: Error) => err.message);
await vi.waitFor(() => expect(client.written).toHaveLength(2));
client.exit();
expect(await read).toBe("spawned read\n");
expect(await write).toBe("tmux control client ended: exited (SIGTERM)");
expect(spawned).toEqual([readArgv, ["tmux", ...serverArgs, "display-message", "-p", "-t", "=seat@rig:", ""]]);
await exec.close();
});
it("drops a client once the reply at the head of its line is overdue, and starts a new one for the next command", async () => {
const clients = [fakeClient(), fakeClient()];
let started = 0;
@@ -0,0 +1,175 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { prepareHermeticEnv, type HermeticScaffold } from "./helpers/hermetic-env.js";
import { runRig, spawnScenarioDaemon, type ScenarioDaemon } from "./helpers/scenario-daemon.js";
import { stageTopologyRoot } from "./helpers/scenario-stage.js";
import type { TmuxServerSelection } from "../src/domain/tmux-server-selection.js";
// The scenario grammar has no config-write or socket-inventory surface. Use its
// normal scaffold, daemon lifecycle, topology staging and stub seat, with native
// observations around real CLI commands. No model or desktop provider is used.
const HERE = dirname(fileURLToPath(import.meta.url));
const RIG_BIN = resolve(HERE, "../../cli/dist/bin-wrapper.js");
const SEAT = "dev-worker@scn-baton";
const nativeAvailable = process.platform !== "win32" && (() => {
try { execFileSync("tmux", ["-V"], { stdio: "ignore" }); return true; }
catch { return false; }
})();
describe.skipIf(!nativeAvailable)("daemon tmux server selection with a real stub seat", () => {
let scaffold: HermeticScaffold | undefined;
let daemon: ScenarioDaemon | undefined;
const sockets = new Set<string>();
const native = (socket: string, args: string[]) => execFileSync("tmux", ["-S", socket, ...args], {
encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"],
env: { ...scaffold!.env, PATH: process.env.PATH },
}).trim();
afterEach(async () => {
await daemon?.sigterm();
if (scaffold) {
// Even a startup/assertion failure must clean up an owned dedicated socket.
const record = join(scaffold.openrigHome, "tmux-server.json");
if (existsSync(record)) sockets.add(JSON.parse(readFileSync(record, "utf8")).socketPath);
const root = realpathSync(scaffold.root);
for (const socket of sockets) {
const within = relative(root, socket);
if (isAbsolute(within) || within === ".." || within.startsWith(`..${sep}`)) {
throw new Error("Refusing to clean up a tmux socket outside this fixture");
}
try { native(socket, ["kill-server"]); } catch { /* Already stopped is normal. */ }
}
scaffold.cleanup();
}
sockets.clear();
scaffold = undefined;
daemon = undefined;
});
it("retains live legacy seats, migrates after down, and routes later reads, attachments and destruction to the selected server", async () => {
scaffold = prepareHermeticEnv({ baseEnv: {
HOME: process.env.HOME, PATH: process.env.PATH, TERM: "xterm-256color", SHELL: "/bin/sh", MOTD_SHOWN: "update-motd",
} });
native(scaffold.tmuxSocketPath, ["-f", "/dev/null", "new-session", "-d", "-s", "personal", "sleep 600"]);
const personal = realpathSync(scaffold.tmuxSocketPath);
sockets.add(personal);
writeFileSync(join(scaffold.home, ".tmux.conf"), "set-option -g @openrig-isolation-fixture loaded\n");
const staged = stageTopologyRoot(resolve(HERE, "fixtures/scenarios/topo-stub-baton.yaml"), join(scaffold.root, "topology"));
const active = await spawnScenarioDaemon(scaffold, { rigBin: RIG_BIN, timeoutMs: 60_000 });
daemon = active;
const rig = async (args: string[], env = active.readEnv) => {
const result = await runRig(args, env, RIG_BIN, 120_000);
expect(result.code, `${args.join(" ")}: ${result.stderr}\n${result.stdout}`).toBe(0);
return result;
};
const status = async (): Promise<TmuxServerSelection> => {
const data = JSON.parse((await rig(["terminal", "status", "--json"])).stdout);
expect(data.tmux).toBeDefined();
sockets.add(data.tmux.socketPath);
return data.tmux;
};
const inventory = async () => {
const rigs = JSON.parse((await rig(["ps", "--json"])).stdout) as Array<{ rigId: string; rigName: string }>;
const id = rigs.find(row => row.rigName === "scn-baton")!.rigId;
const response = await fetch(`${active.baseUrl}/api/rigs/${id}/nodes`);
expect(response.ok).toBe(true);
return { id, rows: await response.json() as Array<{ canonicalSessionName: string; tmuxServerArgs: string[]; tmuxServerMode: string; tmuxAttachCommand: string }> };
};
expect(await status()).toMatchObject({ mode: "inherit", socketPath: personal, executionArgs: [], migrationPending: false });
await rig(["up", staged.topologyPath, "--json", "--yes"]);
const oldPane = native(personal, ["display-message", "-p", "-t", `=${SEAT}:`, "#{pane_id}:#{pane_pid}"]);
expect(oldPane).toMatch(/^%\d+:\d+$/);
const initialNodes = await inventory();
expect(initialNodes.rows.map(row => row.canonicalSessionName)).toContain(SEAT);
expect(initialNodes.rows.find(row => row.canonicalSessionName === SEAT)!.tmuxServerMode).toBe("inherit");
expect(native(personal, ["show-environment", "-t", `=${SEAT}`, "OPENRIG_HOME"]))
.toBe(`OPENRIG_HOME=${realpathSync(scaffold.openrigHome)}`);
const configured = await rig(["config", "set", "terminal.tmux_server", "isolated"]);
expect(configured.stderr).toContain("surviving sessions keep their previous server");
await active.restart();
expect(native(personal, ["display-message", "-p", "-t", `=${SEAT}:`, "#{pane_id}:#{pane_pid}"])).toBe(oldPane);
expect(await status()).toMatchObject({ mode: "inherit", requestedMode: "isolated", socketPath: personal,
migrationPending: true, blockingSessions: [SEAT] });
expect((await inventory()).rows.find(row => row.canonicalSessionName === SEAT)!.tmuxServerArgs).toEqual(["-S", personal]);
expect((await rig(["capture", SEAT, "--json"])).stdout).toContain("READY");
await rig(["down", "scn-baton", "--json", "--force"]);
expect(native(personal, ["list-sessions", "-F", "#{session_name}"])).toBe("personal");
await active.restart();
const selected = await status();
expect(selected).toMatchObject({ mode: "isolated", executionArgs: selected.serverArgs, migrationPending: false, blockingSessions: [] });
expect(selected.socketPath).not.toBe(personal);
// Restore the deliberately stopped team. The stub has no provider resume
// history, so explicitly request a fresh seat within the same generation.
// Fresh launches truthfully report partial restoration (CLI exit 1).
const restored = await runRig(["up", "scn-baton", "--existing", "--fresh", "dev.worker", "--json", "--yes"],
active.readEnv, RIG_BIN, 120_000);
expect(restored.code, `${restored.stderr}\n${restored.stdout}`).toBe(1);
expect(JSON.parse(restored.stdout)).toMatchObject({ status: "restored", rigId: initialNodes.id,
rigResult: "partially_restored", nodes: [{ logicalId: "dev.worker", status: "fresh-primed" }] });
expect(native(selected.socketPath!, ["list-sessions", "-F", "#{session_name}"])).toBe(SEAT);
expect(native(personal, ["list-sessions", "-F", "#{session_name}"])).toBe("personal");
expect(native(selected.socketPath!, ["show-options", "-gqv", "@openrig-isolation-fixture"])).toBe("loaded");
expect(native(personal, ["show-options", "-gqv", "@openrig-isolation-fixture"])).toBe("");
const nodes = await inventory();
expect(nodes.id).toBe(initialNodes.id);
expect(nodes.rows).toHaveLength(1);
expect(nodes.rows[0]!.tmuxServerArgs).toEqual(selected.serverArgs);
expect(nodes.rows[0]!.tmuxServerMode).toBe("isolated");
expect(nodes.rows[0]!.tmuxAttachCommand).toContain(selected.socketPath!);
const preview = await (await fetch(`${active.baseUrl}/api/terminal/preview?view=scn-baton&provider=herdr`)).json();
expect(preview.tmux).toMatchObject({ mode: "isolated", socketPath: selected.socketPath });
expect(preview.composed.localTmuxServer).toEqual(selected.serverArgs);
expect(preview.composed.opened[0].paneCommand).toContain(selected.socketPath!);
// A personal namesake must neither capture nor adopt as this instance's seat.
native(personal, ["new-session", "-d", "-s", SEAT, "printf 'PERSONAL_NAMESAKE\\n'; sleep 600"]);
const personalPane = native(personal, ["display-message", "-p", "-t", `=${SEAT}:`, "#{pane_id}"]);
expect(personalPane).toMatch(/^%\d+$/);
const capture = await rig(["capture", SEAT, "--json"]);
expect(capture.stdout).toContain("READY");
expect(capture.stdout).not.toContain("PERSONAL_NAMESAKE");
const attach = await runRig(["attach", "--self", "--rig", nodes.id, "--pod", "dev", "--member", "foreign", "--runtime", "stub", "--json"],
{ ...active.readEnv, TMUX: `${personal},1,0`, TMUX_PANE: personalPane }, RIG_BIN);
expect(attach.code, `${attach.stderr}\n${attach.stdout}`).toBe(1);
expect(JSON.parse(attach.stdout)).toMatchObject({ code: "tmux_server_mismatch" });
expect((await inventory()).rows).toHaveLength(1);
const isolatedPane = native(selected.socketPath!, ["display-message", "-p", "-t", `=${SEAT}:`, "#{pane_id}:#{pane_pid}"]);
expect(isolatedPane).toMatch(/^%\d+:\d+$/);
scaffold.env.TMUX = `${personal},1,0`;
scaffold.env.TMUX_TMPDIR = join(scaffold.root, "different-tmp");
mkdirSync(scaffold.env.TMUX_TMPDIR);
await active.restart();
expect(await status()).toMatchObject({ mode: "isolated", socketPath: selected.socketPath, migrationPending: false });
expect(native(selected.socketPath!, ["display-message", "-p", "-t", `=${SEAT}:`, "#{pane_id}:#{pane_pid}"])).toBe(isolatedPane);
const destroyEnv = {
...active.readEnv, OPENRIG_PORT: String(active.port), OPENRIG_DB: join(scaffold.stateDir, "scenario.db"),
};
const recordFile = join(scaffold.openrigHome, "tmux-server.json");
const recorded = readFileSync(recordFile, "utf8");
unlinkSync(recordFile);
try {
const refused = await runRig(["destroy", "--all", "--yes", "--confirm", "destroy-openrig-state"], destroyEnv, RIG_BIN);
expect(refused.code, `${refused.stderr}\n${refused.stdout}`).toBe(1);
expect(`${refused.stderr}\n${refused.stdout}`).toContain("Missing tmux server record");
expect(existsSync(destroyEnv.OPENRIG_DB)).toBe(true);
expect(await status()).toMatchObject({ mode: "isolated", socketPath: selected.socketPath });
expect(native(selected.socketPath!, ["display-message", "-p", "-t", `=${SEAT}:`, "#{pane_id}:#{pane_pid}"])).toBe(isolatedPane);
expect(native(personal, ["capture-pane", "-p", "-t", `=${SEAT}:`])).toContain("PERSONAL_NAMESAKE");
} finally {
writeFileSync(recordFile, recorded, { mode: 0o600 });
}
await rig(["destroy", "--all", "--yes", "--confirm", "destroy-openrig-state"], destroyEnv);
daemon = undefined;
expect(() => native(selected.socketPath!, ["has-session", "-t", `=${SEAT}`])).toThrow();
expect(native(personal, ["has-session", "-t", `=${SEAT}`])).toBe("");
expect(native(personal, ["capture-pane", "-p", "-t", `=${SEAT}:`])).toContain("PERSONAL_NAMESAKE");
// Several daemon restarts and shipped CLI processes share the suite's worker budget.
}, 420_000);
});
@@ -0,0 +1,117 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TmuxAdapter } from "../src/adapters/tmux.js";
import { execArgvCommand, execCommand } from "../src/adapters/tmux-exec.js";
import { selectTmuxServer } from "../src/domain/tmux-server-selection.js";
const nativeAvailable = process.platform !== "win32" && (() => {
try { execFileSync("tmux", ["-V"], { stdio: "ignore" }); return true; }
catch { return false; }
})();
describe.skipIf(!nativeAvailable)("an explicit tmux server survives an inherited personal server", { timeout: 30_000 }, () => {
let root: string;
let personal: string;
let isolated: string;
let selectedSockets: string[];
const native = (socket: string, args: string[]) =>
execFileSync("tmux", ["-S", socket, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
beforeEach(() => {
// A short prefix leaves room for macOS's canonical /private path.
root = fs.mkdtempSync(path.join(os.tmpdir(), "t"));
selectedSockets = [];
personal = path.join(root, "personal.sock");
isolated = path.join(root, "isolated.sock");
vi.stubEnv("HOME", root);
vi.stubEnv("SHELL", "/bin/sh");
native(personal, ["-f", "/dev/null", "new-session", "-d", "-s", "personal", "sleep 120"]);
vi.stubEnv("TMUX", `${personal},0,0`);
vi.stubEnv("TMUX_PANE", "%0");
});
afterEach(() => {
for (const socket of new Set([personal, isolated, ...selectedSockets])) {
try { native(socket, ["kill-server"]); } catch { /* The fixture may not have started this server. */ }
}
vi.unstubAllEnvs();
fs.rmSync(root, { recursive: true, force: true });
});
it("keeps identically named seats in two homes on different servers", async () => {
for (const tag of ["a", "b"]) {
const home = path.join(root, tag);
const selected = await selectTmuxServer({ home, mode: "isolated", registeredSessions: [],
env: { TMUX: `${personal},1,0`, TMUX_TMPDIR: root } });
selectedSockets.push(selected.socketPath!);
const adapter = new TmuxAdapter(execCommand, undefined, execArgvCommand, selected.executionArgs);
expect(await adapter.createSession("operator@kernel", root, { OPENRIG_HOME: home, OPENRIG_TEST_MARKER: tag }))
.toMatchObject({ ok: true });
expect(native(selected.socketPath!, ["show-environment", "-t", "=operator@kernel", "OPENRIG_TEST_MARKER"]))
.toBe(`OPENRIG_TEST_MARKER=${tag}`);
}
expect(new Set(selectedSockets).size).toBe(2);
for (const socket of selectedSockets) {
expect(native(socket, ["list-sessions", "-F", "#{session_name}"])).toBe("operator@kernel");
}
expect(native(personal, ["list-sessions", "-F", "#{session_name}"])).toBe("personal");
});
it("migrates past foreign namesakes but retains its own legacy seat until it stops", async () => {
// Outside tmux, a non-UTF-8 locale sanitizes the tab separator. An inherited
// TMUX marker implicitly enables UTF-8 and would hide this regression.
vi.stubEnv("LC_ALL", "C");
vi.stubEnv("LC_CTYPE", "C");
vi.stubEnv("LANG", "C");
vi.stubEnv("TMUX", undefined);
const homeA = path.join(root, "a");
const homeB = path.join(root, "b-ö");
native(personal, ["new-session", "-d", "-s", "operator@kernel", "-e", `OPENRIG_HOME=${homeB}`, "sleep 120"]);
const request = { mode: "isolated" as const, registeredSessions: ["operator@kernel"],
env: { TMUX: `${personal},1,0`, TMUX_TMPDIR: root } };
const a = await selectTmuxServer({ ...request, home: homeA });
selectedSockets.push(a.socketPath!);
expect(a).toMatchObject({ mode: "isolated", migrationPending: false });
const b = await selectTmuxServer({ ...request, home: homeB });
expect(b).toMatchObject({ mode: "inherit", socketPath: personal, migrationPending: true,
blockingSessions: ["operator@kernel"] });
expect(native(personal, ["has-session", "-t", "=operator@kernel"])).toBe("");
native(personal, ["kill-session", "-t", "=operator@kernel"]);
const moved = await selectTmuxServer({ ...request, home: homeB });
selectedSockets.push(moved.socketPath!);
expect(moved).toMatchObject({ mode: "isolated", migrationPending: false });
expect(moved.socketPath).not.toBe(a.socketPath);
expect(native(personal, ["list-sessions", "-F", "#{session_name}"])).toBe("personal");
});
it("can leave an empty persistent server after its last registered seat stops", async () => {
const home = path.join(root, "persistent");
const options = { home, registeredSessions: ["owned"], env: { TMUX: `${personal},1,0`, TMUX_TMPDIR: root } };
const first = await selectTmuxServer({ ...options, mode: "isolated", registeredSessions: [] });
selectedSockets.push(first.socketPath!);
native(first.socketPath!, ["-f", "/dev/null", "new-session", "-d", "-s", "owned", "sleep 120"]);
native(first.socketPath!, ["set-option", "-g", "exit-empty", "off"]);
native(first.socketPath!, ["kill-session", "-t", "=owned"]);
expect(await selectTmuxServer({ ...options, mode: "inherit" })).toMatchObject({
mode: "inherit", socketPath: personal, migrationPending: false,
});
});
for (const transport of ["shell", "argv"] as const) {
it(`${transport}: launch, discovery, environment and cleanup stay on the selected server`, async () => {
const adapter = new TmuxAdapter(execCommand, undefined, transport === "argv" ? execArgvCommand : undefined, ["-S", isolated]);
expect(await adapter.createSession("managed@fixture", root, { OPENRIG_TEST_MARKER: "isolated-seat" })).toMatchObject({ ok: true });
expect((await adapter.listSessions()).map(session => session.name)).toEqual(["managed@fixture"]);
expect(native(personal, ["list-sessions", "-F", "#{session_name}"])).toBe("personal");
expect(native(isolated, ["show-environment", "-t", "=managed@fixture", "OPENRIG_TEST_MARKER"])).toBe("OPENRIG_TEST_MARKER=isolated-seat");
expect(await adapter.createProbeSession("metadata-probe", root)).toMatchObject({ ok: true });
expect(native(personal, ["list-sessions", "-F", "#{session_name}"])).toBe("personal");
expect(await adapter.killSession("metadata-probe")).toMatchObject({ ok: true });
expect(await adapter.killSession("managed@fixture")).toMatchObject({ ok: true });
expect(native(personal, ["list-sessions", "-F", "#{session_name}"])).toBe("personal");
});
}
});
@@ -0,0 +1,384 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { inspectTmuxServer, selectTmuxServer } from "../src/domain/tmux-server-selection.js";
import * as execution from "../src/adapters/tmux-exec.js";
const PINNED_MARKER = "openrig-tmux-pinned-v1\n";
describe("durable tmux server selection", () => {
let root: string;
let home: string;
let oldSocket: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "t"));
home = path.join(root, "home");
oldSocket = path.join(root, "personal.sock");
inspect.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(root, { recursive: true, force: true });
});
const inspect = vi.fn(async () => ({ state: "absent" as const }));
const options = () => ({
home, mode: "inherit" as const, registeredSessions: [] as string[],
platform: "linux" as const, uid: 1000,
env: { TMUX: `${oldSocket},44,0`, TMUX_TMPDIR: root }, inspect,
});
it("leaves inherited execution unchanged while preserving attachment metadata", async () => {
const cases = [
{ env: {}, serverArgs: [] },
{ env: { TMUX: "/tmp/tmux-1000/default,1,0" }, serverArgs: [] },
{ env: { TMUX: "/tmp/tmux-1000/personal,1,0" }, serverArgs: ["-L", "personal"] },
{ env: { TMUX: `${oldSocket},1,0`, TMUX_TMPDIR: root }, serverArgs: ["-S", oldSocket] },
];
for (const { env, serverArgs } of cases) {
expect(await selectTmuxServer({ ...options(), env })).toMatchObject({
mode: "inherit", requestedMode: "inherit", executionArgs: [], serverArgs, migrationPending: false,
});
}
expect(inspect).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(home, "tmux-server.initialized"))).toBe(false);
});
it("follows a new launching environment in inherit even when an advisory record names live sessions", async () => {
await selectTmuxServer(options());
const socket = path.join(root, "different.sock");
const observer = vi.fn(async () => ({ state: "available" as const, sessions: [{ name: "owned" }] }));
expect(await selectTmuxServer({ ...options(), registeredSessions: ["owned"], inspect: observer,
env: { TMUX: `${socket},1,0` } })).toMatchObject({
mode: "inherit", socketPath: socket, executionArgs: [], serverArgs: ["-S", socket], migrationPending: false,
});
expect(observer).not.toHaveBeenCalled();
});
it("keeps inherit usable when an old inherited record disappears, even with a legacy marker", async () => {
await selectTmuxServer(options());
const file = path.join(home, "tmux-server.json");
fs.writeFileSync(path.join(home, "tmux-server.initialized"), "1\n");
fs.unlinkSync(file);
expect(await selectTmuxServer({ ...options(), registeredSessions: ["owned"] })).toMatchObject({
mode: "inherit", socketPath: oldSocket, executionArgs: [], migrationPending: false,
});
expect(inspect).not.toHaveBeenCalled();
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toMatchObject({ mode: "inherit", pinned: false });
expect(fs.existsSync(path.join(home, "tmux-server.initialized"))).toBe(false);
});
it.each(["not-json", "null", '{"version":2}', '{"version":1,"mode":"inherit","home":"/another/home","socketPath":"/another/server"}'])(
"does not refuse inherit or overwrite an unusable advisory record: %s", async invalid => {
await selectTmuxServer(options());
const file = path.join(home, "tmux-server.json");
fs.writeFileSync(file, invalid);
expect(await selectTmuxServer({ ...options(), registeredSessions: ["owned"] })).toMatchObject({
mode: "inherit", socketPath: oldSocket, executionArgs: [], migrationPending: false,
});
expect(inspect).not.toHaveBeenCalled();
expect(fs.readFileSync(file, "utf8")).toBe(invalid);
});
it("tolerates an unreadable inherited record but still refuses it when isolation is requested", async () => {
await selectTmuxServer(options());
const file = path.join(home, "tmux-server.json");
const read = fs.readFileSync;
vi.spyOn(fs, "readFileSync").mockImplementation((target, ...args) => {
if (target === file) throw Object.assign(new Error("record unavailable"), { code: "EACCES" });
return read(target, ...args);
});
expect(await selectTmuxServer(options())).toMatchObject({ mode: "inherit", executionArgs: [] });
await expect(selectTmuxServer({ ...options(), mode: "isolated" })).rejects.toThrow("Cannot read the tmux server record");
});
it("does not require a writable home or create a socket directory in inherit", async () => {
const mkdir = vi.spyOn(fs, "mkdirSync").mockImplementation(() => { throw Object.assign(new Error("read-only home"), { code: "EACCES" }); });
expect(await selectTmuxServer(options())).toMatchObject({ mode: "inherit", executionArgs: [] });
expect(mkdir).toHaveBeenCalledTimes(1);
expect(mkdir).toHaveBeenCalledWith(home, { recursive: true });
});
it("treats inherited record replacement and cleanup failures as advisory", async () => {
await selectTmuxServer(options());
const file = path.join(home, "tmux-server.json");
const original = fs.readFileSync(file, "utf8");
vi.spyOn(fs, "renameSync").mockImplementation(() => { throw new Error("cannot replace advisory record"); });
vi.spyOn(fs, "unlinkSync").mockImplementation(() => { throw new Error("cannot remove temporary file"); });
expect(await selectTmuxServer(options())).toMatchObject({ mode: "inherit", executionArgs: [] });
expect(fs.readFileSync(file, "utf8")).toBe(original);
});
it("keeps a recorded live server across configuration and launching-terminal changes, then migrates after it drains", async () => {
await selectTmuxServer(options());
const observer = vi.fn(async () => ({ state: "available" as const, sessions: [{ name: "personal" }, { name: "owned@team" }] }));
const request = { ...options(), mode: "isolated" as const, registeredSessions: ["owned@team"], inspect: observer,
env: { TMUX: `${path.join(root, "different.sock")},88,0`, TMUX_TMPDIR: root } };
const deferred = await selectTmuxServer(request);
expect(deferred).toMatchObject({ mode: "inherit", requestedMode: "isolated", socketPath: oldSocket,
migrationPending: true, blockingSessions: ["owned@team"], serverArgs: ["-S", oldSocket], executionArgs: ["-S", oldSocket] });
expect(observer).toHaveBeenCalledWith(oldSocket);
expect(JSON.parse(fs.readFileSync(path.join(home, "tmux-server.json"), "utf8")))
.toMatchObject({ mode: "inherit", pinned: true, socketPath: oldSocket });
expect(fs.readFileSync(path.join(home, "tmux-server.initialized"), "utf8")).toBe(PINNED_MARKER);
observer.mockResolvedValue({ state: "available", sessions: [{ name: "personal" }] });
const moved = await selectTmuxServer(request);
expect(moved).toMatchObject({ mode: "isolated", migrationPending: false, blockingSessions: [] });
expect(moved.socketPath).not.toBe(oldSocket);
expect(moved.executionArgs).toEqual(["-S", moved.socketPath]);
expect(fs.readFileSync(path.join(home, "tmux-server.json"), "utf8")).toContain(moved.socketPath!);
});
it("retains a pending pin when isolation is cancelled from a different launching server", async () => {
await selectTmuxServer(options());
const request = { ...options(), registeredSessions: ["owned"],
env: { TMUX: `${path.join(root, "different.sock")},1,0`, TMUX_TMPDIR: root },
inspect: vi.fn(async () => ({ state: "available" as const, sessions: [{ name: "owned" }] })) };
await selectTmuxServer({ ...request, mode: "isolated" });
expect(await selectTmuxServer(request)).toMatchObject({
mode: "inherit", requestedMode: "inherit", socketPath: oldSocket, executionArgs: ["-S", oldSocket], migrationPending: true,
});
expect(JSON.parse(fs.readFileSync(path.join(home, "tmux-server.json"), "utf8"))).toMatchObject({ pinned: true });
});
it("does not mistake an inaccessible old server for an empty one", async () => {
await selectTmuxServer(options());
const result = await selectTmuxServer({ ...options(), mode: "isolated", registeredSessions: ["owned"],
inspect: async () => ({ state: "unavailable" }) });
expect(result).toMatchObject({ socketPath: oldSocket, mode: "inherit", migrationPending: true });
expect(result.note).toContain("could not be inspected");
});
it("also preserves live isolated seats when isolation is disabled", async () => {
const first = await selectTmuxServer({ ...options(), mode: "isolated" });
const result = await selectTmuxServer({ ...options(), registeredSessions: ["owned"],
inspect: async () => ({ state: "available", sessions: [{ name: "owned" }] }) });
expect(result).toMatchObject({ requestedMode: "inherit", mode: "isolated", socketPath: first.socketPath,
executionArgs: ["-S", first.socketPath], migrationPending: true, blockingSessions: ["owned"] });
});
it("keeps an uninspectable isolated server selected while disabling isolation", async () => {
const first = await selectTmuxServer({ ...options(), mode: "isolated" });
expect(await selectTmuxServer({ ...options(), registeredSessions: ["owned"],
inspect: async () => ({ state: "unavailable" }) })).toMatchObject({
requestedMode: "inherit", mode: "isolated", socketPath: first.socketPath,
executionArgs: ["-S", first.socketPath], migrationPending: true,
});
});
it("returns to ordinary inherited execution after isolated seats drain", async () => {
const first = await selectTmuxServer({ ...options(), mode: "isolated" });
const result = await selectTmuxServer({ ...options(), registeredSessions: ["owned"],
inspect: async () => ({ state: "absent" }) });
expect(result).toMatchObject({ requestedMode: "inherit", mode: "inherit", socketPath: oldSocket,
executionArgs: [], serverArgs: ["-S", oldSocket], migrationPending: false });
expect(JSON.parse(fs.readFileSync(path.join(home, "tmux-server.json"), "utf8")))
.toMatchObject({ mode: "inherit", pinned: false, socketPath: oldSocket, isolatedSocketPath: first.socketPath });
expect(fs.existsSync(path.join(home, "tmux-server.initialized"))).toBe(false);
});
it("does not leave a stale pinned cleanup target when committing the return to inherit fails", async () => {
await selectTmuxServer({ ...options(), mode: "isolated" });
const file = path.join(home, "tmux-server.json");
const original = fs.readFileSync(file, "utf8");
const failure = Object.assign(new Error("cannot commit pin release"), { code: "EIO" });
const rename = fs.renameSync;
vi.spyOn(fs, "renameSync").mockImplementation((source, target) => {
if (target === file) throw failure;
return rename(source, target);
});
await expect(selectTmuxServer(options())).rejects.toBe(failure);
expect(fs.readFileSync(file, "utf8")).toBe(original);
expect(fs.readFileSync(path.join(home, "tmux-server.initialized"), "utf8")).toBe(PINNED_MARKER);
});
it("retries marker cleanup after a committed pin release without resurrecting the old pin", async () => {
await selectTmuxServer({ ...options(), mode: "isolated" });
const marker = path.join(home, "tmux-server.initialized");
const unlink = fs.unlinkSync;
const failure = Object.assign(new Error("cannot remove pin marker"), { code: "EACCES" });
const cleanup = vi.spyOn(fs, "unlinkSync").mockImplementation(file => {
if (file === marker) throw failure;
return unlink(file);
});
await expect(selectTmuxServer(options())).rejects.toBe(failure);
expect(JSON.parse(fs.readFileSync(path.join(home, "tmux-server.json"), "utf8")))
.toMatchObject({ mode: "inherit", pinned: false, socketPath: oldSocket });
expect(fs.readFileSync(marker, "utf8")).toBe(PINNED_MARKER);
cleanup.mockRestore();
expect(await selectTmuxServer(options())).toMatchObject({ mode: "inherit", executionArgs: [] });
expect(fs.existsSync(marker)).toBe(false);
});
it("upgrades a legacy isolated record before retaining its live socket", async () => {
const isolated = await selectTmuxServer({ ...options(), mode: "isolated" });
const file = path.join(home, "tmux-server.json");
const legacy = JSON.parse(fs.readFileSync(file, "utf8"));
delete legacy.pinned;
fs.writeFileSync(file, JSON.stringify(legacy));
fs.writeFileSync(path.join(home, "tmux-server.initialized"), "1\n");
expect(await selectTmuxServer({ ...options(), registeredSessions: ["owned"],
inspect: async () => ({ state: "available", sessions: [{ name: "owned" }] }) })).toMatchObject({
mode: "isolated", socketPath: isolated.socketPath, migrationPending: true,
});
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toMatchObject({ pinned: true });
expect(fs.readFileSync(path.join(home, "tmux-server.initialized"), "utf8")).toBe(PINNED_MARKER);
});
it("keeps the dedicated socket when a later shell changes TMUX and TMUX_TMPDIR", async () => {
const first = await selectTmuxServer({ ...options(), mode: "isolated" });
const second = await selectTmuxServer({ ...options(), mode: "isolated",
env: { TMUX: `${oldSocket},999,0`, TMUX_TMPDIR: path.join(root, "other-temp") } });
expect(second.socketPath).toBe(first.socketPath);
expect(second.serverArgs).toEqual(first.serverArgs);
});
it("uses different sockets for different homes, without importing the person's server", async () => {
const a = await selectTmuxServer({ ...options(), mode: "isolated" });
const b = await selectTmuxServer({ ...options(), home: path.join(root, "home-b"), mode: "isolated" });
expect(a.socketPath).not.toBe(b.socketPath);
expect(a.socketPath).not.toBe(oldSocket);
expect(b.socketPath).not.toBe(oldSocket);
});
it("ignores explicit foreign-home namesakes, while preserving owned and unmarked legacy sessions", async () => {
const observation = { state: "available" as const, sessions: [
{ name: "foreign", home: path.join(root, "another-home") },
{ name: "owned", home },
{ name: "legacy" },
] };
const selected = await selectTmuxServer({ ...options(), mode: "isolated",
registeredSessions: ["foreign", "owned", "legacy"], inspect: async () => observation });
expect(selected).toMatchObject({ socketPath: oldSocket, migrationPending: true, blockingSessions: ["owned", "legacy"] });
expect(await selectTmuxServer({ ...options(), mode: "isolated", registeredSessions: ["foreign"],
inspect: async () => observation })).toMatchObject({ mode: "isolated", migrationPending: false });
});
it("reads session ownership from the inspected server", async () => {
vi.spyOn(execution, "execArgvCommand").mockResolvedValue(`owned\t${home}\nlegacy\t\n`);
expect(await inspectTmuxServer(oldSocket)).toEqual({ state: "available", sessions: [
{ name: "owned", home }, { name: "legacy" },
] });
expect(execution.execArgvCommand).toHaveBeenCalledWith([
"tmux", "-u", "-S", oldSocket, "list-sessions", "-F", "#{session_name}\t#{OPENRIG_HOME}",
]);
});
it("does not interpret malformed successful output as an empty server", async () => {
vi.spyOn(execution, "execArgvCommand").mockResolvedValue("owned_unparseable-home\n");
expect(await inspectTmuxServer(oldSocket)).toEqual({ state: "unavailable" });
});
it.skipIf(process.platform === "win32")("canonicalizes home aliases to the same namespace", async () => {
const first = await selectTmuxServer({ ...options(), mode: "isolated" });
const alias = path.join(root, "alias");
fs.symlinkSync(home, alias);
const second = await selectTmuxServer({ ...options(), home: alias, mode: "isolated" });
expect(second.socketPath).toBe(first.socketPath);
});
it.skipIf(process.platform === "win32")("canonicalizes a new home beneath a symlinked parent before recording isolation", async () => {
const parent = path.join(root, "real-parent");
const alias = path.join(root, "alias-parent");
fs.mkdirSync(parent);
fs.symlinkSync(parent, alias);
const newHome = path.join(alias, "new-home");
const first = await selectTmuxServer({ ...options(), home: newHome, mode: "isolated" });
const second = await selectTmuxServer({ ...options(), home: newHome, mode: "isolated" });
expect(second.socketPath).toBe(first.socketPath);
expect(JSON.parse(fs.readFileSync(path.join(newHome, "tmux-server.json"), "utf8")))
.toMatchObject({ home: fs.realpathSync(newHome), pinned: true });
});
it("keeps an inherited socket path containing commas intact", async () => {
const socket = path.join(root, "literal, socket");
expect(await selectTmuxServer({ ...options(), env: { TMUX: `${socket},1,2` } }))
.toMatchObject({ socketPath: socket, executionArgs: [], serverArgs: ["-S", socket] });
});
it("does not overwrite an invalid or foreign-home server record", async () => {
await selectTmuxServer(options());
const file = path.join(home, "tmux-server.json");
const invalid = '{"version":2,"socketPath":"unknown"}';
fs.writeFileSync(file, invalid);
await expect(selectTmuxServer({ ...options(), mode: "isolated" })).rejects.toThrow("Invalid tmux server record");
expect(fs.readFileSync(file, "utf8")).toBe(invalid);
});
it("does not guess a new server after an isolated record disappears", async () => {
await selectTmuxServer({ ...options(), mode: "isolated" });
const file = path.join(home, "tmux-server.json");
fs.unlinkSync(file);
const observer = vi.fn(async () => ({ state: "absent" as const }));
await expect(selectTmuxServer({ ...options(), mode: "isolated", registeredSessions: ["owned"], inspect: observer }))
.rejects.toThrow("Missing tmux server record");
expect(fs.existsSync(file)).toBe(false);
expect(observer).not.toHaveBeenCalled();
});
it("keeps the legacy missing-record guard while isolation is explicitly selected", async () => {
await selectTmuxServer({ ...options(), mode: "isolated" });
fs.writeFileSync(path.join(home, "tmux-server.initialized"), "1\n");
fs.unlinkSync(path.join(home, "tmux-server.json"));
await expect(selectTmuxServer({ ...options(), mode: "isolated" })).rejects.toThrow("Missing tmux server record");
});
it.each(["missing", "invalid", "unreadable"])("does not abandon an active pin on an inherit request with a %s record", async problem => {
await selectTmuxServer({ ...options(), mode: "isolated" });
const file = path.join(home, "tmux-server.json");
if (problem === "missing") fs.unlinkSync(file);
else if (problem === "invalid") fs.writeFileSync(file, "not-json");
else {
const read = fs.readFileSync;
vi.spyOn(fs, "readFileSync").mockImplementation((target, ...args) => {
if (target === file) throw Object.assign(new Error("record unavailable"), { code: "EACCES" });
return read(target, ...args);
});
}
await expect(selectTmuxServer({ ...options(), registeredSessions: ["owned"] })).rejects.toThrow(/Missing tmux server record|Cannot read the tmux server record/);
expect(inspect).not.toHaveBeenCalled();
expect(fs.readFileSync(path.join(home, "tmux-server.initialized"), "utf8")).toBe(PINNED_MARKER);
});
it("does not discard invalid pinned metadata just because its marker is gone", async () => {
await selectTmuxServer({ ...options(), mode: "isolated" });
const file = path.join(home, "tmux-server.json");
const invalid = { ...JSON.parse(fs.readFileSync(file, "utf8")), version: 2 };
fs.writeFileSync(file, JSON.stringify(invalid));
fs.unlinkSync(path.join(home, "tmux-server.initialized"));
await expect(selectTmuxServer(options())).rejects.toThrow("Invalid tmux server record");
expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual(invalid);
});
it("preserves the persistence error when temporary-file cleanup also fails", async () => {
await selectTmuxServer(options());
const file = path.join(home, "tmux-server.json");
const original = fs.readFileSync(file, "utf8");
const failure = Object.assign(new Error("cannot replace server record"), { code: "EIO" });
vi.spyOn(fs, "renameSync").mockImplementation(() => { throw failure; });
vi.spyOn(fs, "unlinkSync").mockImplementation(() => { throw Object.assign(new Error("cannot remove temporary file"), { code: "EACCES" }); });
await expect(selectTmuxServer({ ...options(), mode: "isolated" })).rejects.toBe(failure);
expect(fs.readFileSync(file, "utf8")).toBe(original);
});
it("reports the unsupported Windows selection without pretending to isolate psmux", async () => {
expect(await selectTmuxServer({ ...options(), platform: "win32", mode: "isolated" })).toMatchObject({
requestedMode: "isolated", mode: "inherit", serverArgs: [], migrationPending: true,
note: expect.stringContaining("POSIX"),
});
expect(fs.existsSync(home)).toBe(false);
});
it.each([
["no server running on /private/socket", "absent"],
["error connecting to /private/socket (No such file or directory)", "absent"],
["error connecting to /private/socket (Connection refused)", "absent"],
["error connecting to /private/socket (Permission denied)", "unavailable"],
["permission denied: no server running", "unavailable"],
["tmux returned incomplete output", "unavailable"],
] as const)("classifies a server probe without hiding access failures: %s", async (message, state) => {
vi.spyOn(execution, "execArgvCommand").mockRejectedValue(new Error(message));
expect(await inspectTmuxServer(oldSocket)).toEqual({ state });
});
});
@@ -0,0 +1,199 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { CONTROL_OPTION, supportsControlMode } from "../src/adapters/tmux-control.js";
import type { TmuxServerSelection } from "../src/domain/tmux-server-selection.js";
import { prepareHermeticEnv, type HermeticScaffold } from "./helpers/hermetic-env.js";
import { runRig, spawnScenarioDaemon, type ScenarioDaemon } from "./helpers/scenario-daemon.js";
import { stageTopologyRoot } from "./helpers/scenario-stage.js";
const HERE = dirname(fileURLToPath(import.meta.url));
const RIG_BIN = resolve(HERE, "../../cli/dist/bin-wrapper.js");
const SEAT = "dev-worker@scn-baton";
const PERSONAL = "PERSONAL_NAMESAKE";
const nativeAvailable = process.platform !== "win32" && (() => {
try { return supportsControlMode(execFileSync("tmux", ["-V"], { encoding: "utf8" })); }
catch { return false; }
})();
interface Instance {
scaffold: HermeticScaffold;
daemon?: ScenarioDaemon;
selection?: TmuxServerSelection;
}
// Two real daemons share a scratch OS home, inherited server and tmux wrapper.
// Only OPENRIG_HOME, database and port distinguish them. Scripted agents prove
// native routing and rendered delivery, not a provider's receipt of a message.
describe.skipIf(!nativeAvailable)("two isolated daemon instances with tmux control mode", () => {
const instances: Instance[] = [];
const sockets = new Set<string>();
const ownedSocket = (socket: string) => {
if (typeof socket !== "string" || !isAbsolute(socket)) throw new Error("Expected an absolute fixture tmux socket");
let target: string;
try { target = realpathSync(socket); }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
target = join(realpathSync(dirname(socket)), basename(socket));
}
if (!instances.some(({ scaffold }) => {
const root = existsSync(scaffold.root) ? realpathSync(scaffold.root) : resolve(scaffold.root);
const within = relative(root, target);
return within !== "" && !isAbsolute(within) && within !== ".." && !within.startsWith(`..${sep}`);
})) throw new Error("Refusing to use a tmux socket outside these fixtures");
return target;
};
const native = (socket: string, args: string[]) => execFileSync("tmux", ["-S", ownedSocket(socket), ...args], {
encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"],
env: { ...instances[0]!.scaffold.env, PATH: process.env.PATH },
}).trim();
afterEach(async () => {
const failures: unknown[] = [];
// Also stop a daemon whose start timed out before the helper returned its
// handle: the CLI resolves only this scaffold's own daemon record.
for (const { scaffold } of instances) {
try {
const stopped = await runRig(["daemon", "stop"], scaffold.env, RIG_BIN, 30_000);
if (stopped.code !== 0) throw new Error(`Fixture daemon stop failed: ${stopped.stderr}\n${stopped.stdout}`);
}
catch (error) { failures.push(error); }
try {
const record = join(scaffold.openrigHome, "tmux-server.json");
if (existsSync(record)) sockets.add(JSON.parse(readFileSync(record, "utf8")).socketPath);
} catch (error) { failures.push(error); }
}
for (const socket of sockets) {
try {
try { native(socket, ["kill-server"]); }
catch (error) {
if (!/no server running|No such file or directory|Connection refused/.test(String(error))) throw error;
}
} catch (error) { failures.push(error); }
}
for (const { scaffold } of instances) {
try { scaffold.cleanup(); }
catch (error) { failures.push(error); }
}
instances.length = 0;
sockets.clear();
if (failures.length) throw new AggregateError(failures, "Two-instance fixture cleanup failed");
}, 120_000);
it("keeps namesake seats, control clients and shutdown scoped to their own instance", async () => {
for (let i = 0; i < 2; i++) instances.push({ scaffold: prepareHermeticEnv({ baseEnv: {
PATH: process.env.PATH, TERM: "xterm-256color", SHELL: "/bin/sh", MOTD_SHOWN: "update-motd",
} }) });
const [a, b] = instances as [Instance, Instance];
const shared = a.scaffold;
const personal = shared.tmuxSocketPath;
sockets.add(personal);
native(personal, ["-f", "/dev/null", "new-session", "-d", "-s", SEAT, `printf '${PERSONAL}\\n'; sleep 600`]);
const paneIdentity = (socket: string) => native(socket, ["display-message", "-p", "-t", `=${SEAT}:`, "#{pane_id}:#{pane_pid}"]);
const personalPane = paneIdentity(personal);
const rig = async (instance: Instance, args: string[], sender = false) => {
const result = await runRig(args, { ...instance.daemon!.readEnv, ...(sender ? { OPENRIG_SESSION_NAME: SEAT } : {}) }, RIG_BIN, 120_000);
expect(result.code, `${args.join(" ")}: ${result.stderr}\n${result.stdout}`).toBe(0);
return result;
};
const inventory = async (instance: Instance) => {
const rigs = JSON.parse((await rig(instance, ["ps", "--json"])).stdout) as Array<{ rigId: string; rigName: string }>;
const id = rigs.find(row => row.rigName === "scn-baton")?.rigId;
expect(id).toBeTruthy();
const response = await fetch(`${instance.daemon!.baseUrl}/api/rigs/${id}/nodes`);
expect(response.ok).toBe(true);
return { id, rows: await response.json() as Array<{
canonicalSessionName: string; tmuxServerArgs: string[]; tmuxServerMode: string; tmuxAttachCommand: string;
}> };
};
const capture = async (instance: Instance) => (await rig(instance, ["capture", SEAT, "--json"])).stdout;
const controlSessions = (socket: string) => native(socket, ["list-sessions", "-F", `#{session_name}|#{${CONTROL_OPTION}}`])
.split("\n").filter(row => row.split("|")[1]).map(row => row.split("|")[0]!);
for (const [index, instance] of instances.entries()) {
const { scaffold } = instance;
Object.assign(scaffold.env, {
HOME: shared.home, XDG_CONFIG_HOME: shared.env.XDG_CONFIG_HOME, XDG_STATE_HOME: shared.env.XDG_STATE_HOME,
XDG_DATA_HOME: shared.env.XDG_DATA_HOME, XDG_CACHE_HOME: shared.env.XDG_CACHE_HOME,
PATH: shared.env.PATH, TMUX: `${personal},1,0`, TMUX_TMPDIR: shared.tmuxTmpDir,
OPENRIG_TMUX_SERVER: "isolated", OPENRIG_TMUX_CONTROL: "1",
});
const staged = stageTopologyRoot(resolve(HERE, "fixtures/scenarios/topo-stub-baton.yaml"), join(scaffold.root, "topology"));
const scriptDir = join(staged.seatCwds["dev-worker"]!, ".openrig", "stub");
mkdirSync(scriptDir, { recursive: true });
writeFileSync(join(scriptDir, "script.json"), JSON.stringify({ steps: [{ kind: "say", text: `INSTANCE_${index}_READY` }] }));
try {
instance.daemon = await spawnScenarioDaemon(scaffold, { rigBin: RIG_BIN, timeoutMs: 60_000 });
} catch (error) {
const log = join(scaffold.openrigHome, "daemon.log");
const detail = existsSync(log) ? readFileSync(log, "utf8").slice(-8_000) : "No daemon log was written.";
throw new Error(`${error instanceof Error ? error.message : String(error)}\nFixture daemon log:\n${detail}`, { cause: error });
}
const status = JSON.parse((await rig(instance, ["terminal", "status", "--json"])).stdout);
instance.selection = status.tmux as TmuxServerSelection;
expect(instance.selection).toMatchObject({ requestedMode: "isolated", mode: "isolated", migrationPending: false });
const socket = ownedSocket(instance.selection.socketPath!);
expect(socket).not.toBe(personal);
sockets.add(socket);
await rig(instance, ["up", staged.topologyPath, "--json", "--yes"]);
expect(native(socket, ["display-message", "-p", "-t", `=${SEAT}:`, "#{socket_path}"])).toBe(socket);
expect(native(socket, ["show-environment", "-t", `=${SEAT}`, "OPENRIG_HOME"]))
.toBe(`OPENRIG_HOME=${realpathSync(scaffold.openrigHome)}`);
const paneHome = native(socket, ["show-environment", "-t", `=${SEAT}`, "HOME"]);
expect(paneHome).toMatch(/^HOME=/);
expect(realpathSync(paneHome.slice(5))).toBe(realpathSync(shared.home));
}
expect(a.scaffold.openrigHome).not.toBe(b.scaffold.openrigHome);
expect(a.daemon!.port).not.toBe(b.daemon!.port);
expect(a.selection!.socketPath).not.toBe(b.selection!.socketPath);
expect(existsSync(join(a.scaffold.stateDir, "scenario.db"))).toBe(true);
expect(existsSync(join(b.scaffold.stateDir, "scenario.db"))).toBe(true);
const [nodesA, nodesB] = await Promise.all([inventory(a), inventory(b)]);
expect(nodesA.id).not.toBe(nodesB.id);
for (const [instance, nodes] of [[a, nodesA], [b, nodesB]] as const) {
expect(nodes.rows).toHaveLength(1);
expect(nodes.rows[0]).toMatchObject({ canonicalSessionName: SEAT,
tmuxServerArgs: instance.selection!.serverArgs, tmuxServerMode: "isolated" });
expect(nodes.rows[0]!.tmuxAttachCommand).toContain(instance.selection!.socketPath!);
}
for (const [index, instance] of instances.entries()) {
const initial = await capture(instance);
expect(initial).toContain(`INSTANCE_${index}_READY`);
expect(initial).not.toContain(`INSTANCE_${1 - index}_READY`);
expect(initial).not.toContain(PERSONAL);
const sent = JSON.parse((await rig(instance, ["send", SEAT, `INSTANCE_${index}_WRITE`, "--verify", "--json"], true)).stdout);
expect(sent.verified).toBe(true);
}
for (const [index, instance] of instances.entries()) {
const observed = await capture(instance);
expect(observed).toContain(`INSTANCE_${index}_WRITE`);
expect(observed).not.toContain(`INSTANCE_${1 - index}_WRITE`);
expect(observed).not.toContain(PERSONAL);
}
// Setting the environment alone is not proof that the control path ran.
const controlsA = controlSessions(a.selection!.socketPath!);
const controlsB = controlSessions(b.selection!.socketPath!);
expect(controlsA).toHaveLength(1);
expect(controlsB).toHaveLength(1);
expect(controlSessions(personal)).toEqual([]);
const survivingPane = paneIdentity(b.selection!.socketPath!);
await rig(a, ["down", "scn-baton", "--json", "--force", "--yes"]);
const stopped = await runRig(["daemon", "stop"], a.scaffold.env, RIG_BIN, 30_000);
expect(stopped.code, `${stopped.stderr}\n${stopped.stdout}`).toBe(0);
expect(() => native(a.selection!.socketPath!, ["has-session", "-t", `=${SEAT}`])).toThrow();
expect(paneIdentity(b.selection!.socketPath!)).toBe(survivingPane);
expect(paneIdentity(personal)).toBe(personalPane);
expect(controlSessions(b.selection!.socketPath!)).toEqual(controlsB);
const afterStop = JSON.parse((await rig(b, ["send", SEAT, "INSTANCE_1_SURVIVED", "--verify", "--json"], true)).stdout);
expect(afterStop.verified).toBe(true);
expect(await capture(b)).toContain("INSTANCE_1_SURVIVED");
const personalCapture = native(personal, ["capture-pane", "-p", "-t", `=${SEAT}:`]);
expect(personalCapture).toContain(PERSONAL);
expect(personalCapture).not.toContain("INSTANCE_");
}, 360_000);
});
+2 -1
View File
@@ -43,6 +43,7 @@ describe("the daemon's tmux server", () => {
it("names a socket anywhere else, or another user's, with -S", () => {
expect(tmuxServerArgs({ TMUX: "/srv/sockets/team,9,1" }, 501)).toEqual(["-S", "/srv/sockets/team"]);
expect(tmuxServerArgs({ TMUX: "/srv/sockets/team ,9,1" }, 501)).toEqual(["-S", "/srv/sockets/team "]);
expect(tmuxServerArgs({ TMUX: "/srv/sockets/team,shared,9,1" }, 501)).toEqual(["-S", "/srv/sockets/team,shared"]);
expect(tmuxServerArgs({ TMUX: "/tmp/tmux-501/person,123,0" }, 502)).toEqual(["-S", "/tmp/tmux-501/person"]);
});
@@ -61,7 +62,7 @@ describe("the daemon's tmux server", () => {
const plain = composeView("v", [local, remote], { resolveHost });
expect(plain.opened.map((p) => p.recoveryCommand)).toEqual([
"tmux attach -f ignore-size -t 'dev-review@starter'",
"ssh 'box.example' 'tmux attach -f ignore-size -t '\"'\"'dev-build@starter'\"'\"''",
"ssh -t 'box.example' 'tmux attach -f ignore-size -t '\"'\"'dev-build@starter'\"'\"''",
]);
const onServer = composeView("v", [local, remote], { resolveHost, localTmux: "/usr/bin/tmux", localTmuxServer: ["-L", "person"] });
expect(onServer.opened.map((p) => p.recoveryCommand)).toEqual([
+10 -2
View File
@@ -9,6 +9,7 @@ import type { SnapshotCapture } from "../src/domain/snapshot-capture.js";
import type { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
import { buildAttentionResponse } from "../src/routes/up.js";
import { setDaemonTmuxServer } from "../src/adapters/tmux-server.js";
const VALID_SPEC = `
schema_version: 1
@@ -37,6 +38,8 @@ describe("Up API route", () => {
let tmuxAdapter: ReturnType<typeof createTestApp>["tmuxAdapter"];
beforeEach(() => {
// This file mixes a real daemon composition with unit-only route fixtures.
setDaemonTmuxServer([]);
db = createFullTestDb();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "up-route-"));
// Create test app with real UpCommandRouter fsOps pointing to tmpDir
@@ -50,6 +53,7 @@ describe("Up API route", () => {
});
afterEach(() => {
setDaemonTmuxServer([]);
db.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
@@ -942,7 +946,11 @@ edges: []
expect(buildAttentionResponse(result)).toBeNull();
});
it("HG-4: blocked + attention_required stage → fact/consequence/action with attentionNodes", () => {
it.each([
{ server: [], attach: "tmux attach -t dev-impl@conveyor" },
{ server: ["-S", "/private/selected socket"], attach: "tmux -S '/private/selected socket' attach -t dev-impl@conveyor" },
])("HG-4: blocked + attention_required includes the effective attachment: $attach", ({ server, attach }) => {
setDaemonTmuxServer(server);
const result = {
rigId: "rig-conveyor-1",
stages: [
@@ -965,7 +973,7 @@ edges: []
expect(r!.error.consequence).toMatch(/rig-conveyor-1/);
expect(r!.error.consequence).toMatch(/rig ps/);
expect(r!.error.consequence).toMatch(/attention_required/);
expect(r!.error.action).toMatch(/tmux attach -t dev-impl@conveyor/);
expect(r!.error.action).toContain(attach);
expect(r!.error.action).not.toMatch(/rig setup --cwd/);
// Singular phrasing for a 1-node case
expect(r!.error.action).toMatch(/^Inspect the affected session/);
@@ -23,7 +23,7 @@ function clearEnv(): () => void {
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_DOGFOOD_EVIDENCE_ROOT",
"OPENRIG_WORKSPACE_PROJECTS_ROOT", "OPENRIG_WORKSPACE_CATALOG_PATH",
"OPENRIG_CONTEXT_ROOT", "OPENRIG_CONTEXT_PACKS_ROOT",
"OPENRIG_LAUNCH_NON_INTERRUPTIVE",
"OPENRIG_LAUNCH_NON_INTERRUPTIVE", "OPENRIG_TMUX_SERVER",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT",
"OPENRIG_BUNDLE_SERVICES_ENABLED",
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
@@ -88,7 +88,7 @@ describe("config routes (User Settings v0)", () => {
// + 1 OPR.0.4.0.1 (ui.terminal.max_live_terminals)
// + 2 OPR.0.4.6.MH1 (host.selected / host.name)
// + 1 OPR.0.4.6.WF5 (workflow.exception_routing)
// + 1 OPR.0.4.6.02 (terminal.status_bar — the ratified sole v1 terminal key)
// + 1 OPR.0.4.6.02 (terminal.status_bar)
// + 5 OPR.0.4.6.FS-1 W2 (retention.enabled / transitions_days / watchdog_days /
// watchdog_keep_per_job / batch_size — the CLI-settable queue-retention knobs;
// + retention.usage_samples_days, 51-08 A2)
@@ -116,6 +116,8 @@ describe("config routes (User Settings v0)", () => {
// + 1 output.whoami_peers (slice 15 P5 whoami peer bound; default 8).
// + 1 usage.pi_cost_facts (slice 02 Pi cost from persisted entries; default off).
// + 1 usage.pi_openrouter_charge (slice 02 K4 OpenRouter charge beside the Pi estimate; default off).
// + 1 terminal.tmux_server (per-instance server selection; default inherit).
expect(body.settings["terminal.tmux_server"]).toMatchObject({ value: "inherit", source: "default" });
// + 1 transport.verify_input_box (input-box evidence for verified Claude Code and Codex sends; default off).
expect(body.settings["policies.claude_compaction.preparation_root"]).toMatchObject({ value: "", source: "default" });
// + 1 bundle.services_enabled (service archive packing, preview and target installs; default off).
@@ -129,7 +131,7 @@ describe("config routes (User Settings v0)", () => {
// + 1 daemon.db_conflict (warn by default; optional pre-open conflict stop).
// + 1 provider.selected (the providers chosen at rig setup --providers; default empty).
// + 2 retention.stream_days / stream_max_items (slice 22 outcome 9 stream retention; default 30, 20000).
expect(Object.keys(body.settings).length).toBe(92);
expect(Object.keys(body.settings).length).toBe(93);
expect(body.settings["retention.stream_days"]).toMatchObject({ value: 30, source: "default", defaultValue: 30 });
expect(body.settings["retention.stream_max_items"]).toMatchObject({ value: 20000, source: "default", defaultValue: 20000 });
expect(body.settings["decisions.enabled"]).toMatchObject({ value: false, source: "default", defaultValue: false });
@@ -25,7 +25,7 @@ const DEFAULT_EXTRA_INSTRUCTION_FILE_SUFFIX = "compaction/post-compact-extra.md"
function clearEnv(): () => void {
const keys = [
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_DAEMON_DB_CONFLICT",
"OPENRIG_PORT", "OPENRIG_HOST", "OPENRIG_DB", "OPENRIG_TMUX_SERVER", "OPENRIG_DAEMON_DB_CONFLICT",
"OPENRIG_TRANSCRIPTS_ENABLED", "OPENRIG_TRANSCRIPTS_PATH",
"OPENRIG_TRANSCRIPTS_LINES", "OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS",
"OPENRIG_WORKSPACE_ROOT", "OPENRIG_WORKSPACE_SLICES_ROOT",
@@ -89,6 +89,16 @@ describe("SettingsStore (User Settings v0)", () => {
restoreEnv();
});
it("resolves tmux isolation from the shared config and environment with a strict mode", () => {
const store = new SettingsStore(configPath);
expect(store.resolveOne("terminal.tmux_server")).toMatchObject({ value: "inherit", source: "default" });
store.set("terminal.tmux_server", "isolated");
expect(store.resolveOne("terminal.tmux_server")).toMatchObject({ value: "isolated", source: "file" });
process.env.OPENRIG_TMUX_SERVER = "inherit";
expect(store.resolveOne("terminal.tmux_server")).toMatchObject({ value: "inherit", source: "env" });
expect(() => store.set("terminal.tmux_server", "isolate")).toThrow('must be "inherit" or "isolated"');
});
it("SETTINGS_VALID_KEYS matches the documented settings key set", () => {
expect([...SETTINGS_VALID_KEYS]).toEqual([
"daemon.port", "daemon.host", "daemon.db_conflict",
@@ -156,6 +166,7 @@ describe("SettingsStore (User Settings v0)", () => {
"snapshots.periodic.retention_keep",
// OPR.0.4.6.02 S1 — inner-tmux status-bar launch default (static bool).
"terminal.status_bar",
"terminal.tmux_server",
"terminal.history_limit",
"terminal.inherit_tmux_environment",
// Slice 15 (OPR.0.7.0.15) — compact command-output defaults switch.
+2 -1
View File
@@ -142,7 +142,8 @@ export function terminalLines(state: ViewState, snap: FleetSnapshot, width: numb
lines.push({ text: `Headless or remote: open a NEW terminal/tab on the machine running the selected daemon (${read.daemonTarget ?? "address unreported"}), then attach with one command below per terminal. Existing conversations stay in place.` });
lines.push({ text: "If a command wraps, widen this terminal until it fits on one line before copying." });
for (const member of page) {
const command = (member.recoveryCommand ?? member.paneCommand).replace(/^ssh /, "ssh -t ");
// Current SSH commands already request a TTY; older daemons may omit it.
const command = (member.recoveryCommand ?? member.paneCommand).replace(/^ssh (?!-t(?:\s|$))/, "ssh -t ");
lines.push({ text: `${member.label} · ${member.seat}` }, { text: `env -u TMUX ${command}` });
}
}
+4 -2
View File
@@ -181,10 +181,12 @@ describe("terminal browser → preview → explicit Open", () => {
expect(text).toContain("selected daemon's desktop");
expect(text).toContain("Headless or remote");
const command = snap.terminals!.preview!.composed.pages[0]![0]!.recoveryCommand!;
expect(text).toContain(`env -u TMUX ${command.replace(/^ssh /, "ssh -t ")}`);
expect(command).toMatch(/^ssh -t /);
expect(text).toContain(`env -u TMUX ${command}`);
expect(text).not.toContain("ssh -t -t");
expect(text).toContain("[email protected]");
expect(text).toContain("seat with spaces");
expect(text).toContain("attach -r");
expect(text).toContain("tmux attach -r -t");
expect(effects).toEqual([]);
});
+1 -1
View File
@@ -39,7 +39,7 @@ export function fixtureUserHomeEnv(userHome: string, platform: NodeJS.Platform =
// Tests can still set explicit overrides after setup; test/provider opt-in flags
// are not instance selectors and remain untouched.
for (const key of [
"URL", "PORT", "HOST", "BIND_HOST", "HOST_SELECTED", "DB",
"URL", "PORT", "HOST", "BIND_HOST", "HOST_SELECTED", "DB", "TMUX_SERVER",
"TRANSCRIPTS_PATH", "WORKSPACE_ROOT", "WORKSPACE_SLICES_ROOT",
"WORKSPACE_STEERING_PATH", "WORKSPACE_SPECS_ROOT", "WORKSPACE_PROJECTS_ROOT",
"WORKSPACE_CATALOG_PATH", "TOPOLOGY_ROOT", "CONTEXT_ROOT", "CONTEXT_PACKS_ROOT",