mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)")
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]'");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user