Compare commits

...
Author SHA1 Message Date
dev-driver b78aa6539d desk: F4 #1191 comparison (test-only, never merged) 2026-10-13 08:58:19 +00:00
orch-leadandClaude Opus 5.5 df7ee77433 fix(seat): accept the staged launch script on macOS, where /bin/sh runs as bash
#1212 round 1. P1-1 (dev60-review, measured on macOS): /bin/sh there is a shim that runs bash (or
dash or zsh), so ps names the staged script's process bash and the new link never matched. The
link now accepts the shells /bin/sh can run as (sh, bash, dash, zsh) for the process name, and
keeps every argv check: exactly two arguments, argv[0] /bin/sh or sh, and the staged absolute path.
A macOS-shaped fixture (bash, /bin/sh /var/folders/.../T/openrig-tmux-send-...) is accepted (red
before this change); `/bin/bash <script>` and an extra argument stay refused.

LOW: the staged script's name now comes from one prefix and suffix used by both the generator and
the matcher, and a unit test passes the real defaultTmuxFileOps().tmpName() through the matcher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 07:33:39 +00:00
orch-leadandClaude Opus 5.5 e773e5b1f3 fix(seat): restart in place accepts a Codex that OpenRig launched through its staged script
OPR.0.7.0.5. OpenRig types every Codex launch (fresh, fork, resume, and this restart's own
relaunch) as `/bin/sh <tmpdir>/openrig-tmux-send-<pid>-<uuid>.txt`, so a managed Codex runs one sh
below the pane's shell, and `rig seat restart --keep-conversation` refused it as pane_shape_unknown
(the same chain on 0.6.10). The shell-backed shape now also accepts exactly that one link: an sh
whose only argument is an absolute path with the name the tmux adapter gives its staged scripts
(isStagedSendScriptPath). Any other process between the shell and Codex is still refused.

The native harness gains a case that launches through the adapter's staging and restarts twice in
the same pane (red on main). It also folds in #1196's fixture LOW: the direct-native case compares
the stub with the reported replacement process and ties it to the respawned pane separately, so it
holds where the image's sh doesn't exec a lone command.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 07:01:46 +00:00
6 changed files with 213 additions and 134 deletions

No files matched your search

+4 -122
View File
@@ -1,26 +1,12 @@
name: Tests
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
# list each published package would contain. Each suite is its own job so one failure does not
# hide the others. No provider credentials or operator configuration are supplied.
# Package suites deny external networking; scenario runtime has no external route.
on:
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-package:
f4-compare:
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
@@ -31,109 +17,5 @@ jobs:
cache: npm
- run: npm ci
- run: npm run build
- name: Record the published file list of each package
run: |
set -euo pipefail
mkdir -p pack-lists
for p in cli daemon tui; do
(cd packages/$p && npm pack --dry-run --json --ignore-scripts) \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{for(const f of JSON.parse(s)[0].files)console.log(f.path)})' \
| sort > pack-lists/$p.txt
echo "$p: $(wc -l < pack-lists/$p.txt) files"
done
- uses: actions/upload-artifact@v4
with:
name: pack-lists
path: pack-lists/
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
repo-checks:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:repo
package-tests:
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
# file-inode behaviour and release tags.
runs-on: macos-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
package: [daemon, cli, tui, ui]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: brew install tmux
- run: npm ci
- name: Build the packages the tests import
run: npm run build
- name: Run the suite without ambient credentials or external network
run: |
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-test.XXXXXX")
suite_root=$(cd "$suite_root" && pwd -P)
mkdir -p "$suite_root/home" "$suite_root/bin"
# Same system bytes, nonprivileged mode; no system file is changed.
/usr/bin/install -m 0755 /bin/ps "$suite_root/bin/ps"
# Only this job's temporary sockets (including tmux) are reachable.
env -i HOME="$suite_root/home" PATH="$PATH" TMPDIR="$suite_root" TMUX_TMPDIR="$suite_root" \
CI=true TERM=xterm-256color \
sandbox-exec -D IPC_ROOT="$suite_root" -p '(version 1)(allow default)(deny network-outbound)(allow network-outbound (remote ip "localhost:*"))(allow network-outbound (subpath (param "IPC_ROOT")))' \
/bin/bash -s "${{ matrix.package }}" <<'SUITE'
set -euo pipefail
node scripts/ci-macos-process-probe.mjs
# The production readers use bare ps. Select real system bytes only after
# self/owned-child observation and network/IPC confinement are proved.
export PATH="$TMPDIR/bin:$PATH"
test "$(command -v ps)" = "$TMPDIR/bin/ps"
exec npm run test -w "packages/$1"
SUITE
installed-scenario:
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Installed package, daemon restart, and seeded durability failure
run: bash scripts/run-pr-scenarios.sh
- uses: actions/upload-artifact@v4
if: always()
with:
name: installed-scenario-evidence
path: dist/pr-scenarios/
if-no-files-found: error
- name: The F4 comparison only
run: npm exec -w packages/daemon -- vitest run test/f4-1191-compare.test.ts --reporter=verbose
+16 -2
View File
@@ -39,11 +39,25 @@ export interface TmuxFileOps {
bufferName(): string;
}
function defaultTmuxFileOps(): TmuxFileOps {
// One source for the staged script's name: the generator (defaultTmuxFileOps) and the matcher
// (isStagedSendScriptPath) are both built from it.
const SEND_SCRIPT_PREFIX = "openrig-tmux-send-";
const SEND_SCRIPT_SUFFIX = ".txt";
const SEND_SCRIPT_NAME = new RegExp(
`^${SEND_SCRIPT_PREFIX}\\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}${SEND_SCRIPT_SUFFIX.replace(".", "\\.")}$`,
);
/** Whether `path` names a launch script sendShellCommand staged (an absolute path with the name
* defaultTmuxFileOps gives it). The script removes itself first, so this is a name check only. */
export function isStagedSendScriptPath(path: string): boolean {
return path.startsWith("/") && SEND_SCRIPT_NAME.test(path.split("/").pop() ?? "");
}
export function defaultTmuxFileOps(): TmuxFileOps {
return {
writeFile: (p, content, options) => fsWriteFile(p, content, { encoding: "utf8", ...options }),
unlink: (p) => fsUnlink(p),
tmpName: () => pathJoin(tmpdir(), `openrig-tmux-send-${process.pid}-${randomUUID()}.txt`),
tmpName: () => pathJoin(tmpdir(), `${SEND_SCRIPT_PREFIX}${process.pid}-${randomUUID()}${SEND_SCRIPT_SUFFIX}`),
bufferName: () => `openrig_${process.pid}_${randomUUID().replace(/-/g, "")}`,
};
}
@@ -3,7 +3,7 @@ import fs from "node:fs";
import nodePath from "node:path";
import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { isStagedSendScriptPath, type TmuxAdapter } from "../adapters/tmux.js";
import type { ArbitratedSeatState } from "./activity-taxonomy.js";
import type { EventBus } from "./event-bus.js";
import type { SessionRegistry } from "./session-registry.js";
@@ -395,9 +395,13 @@ export class CodexInPlaceRestart {
const top = chain.at(-1)!;
const root = rows.find((row) => row.pid === panePid);
let paneShape: PaneShape;
// OpenRig types a Codex launch (and this restart's relaunch) as `/bin/sh <staged script>`, so a managed
// Codex runs one sh below the pane's shell. Only that script is accepted between them.
const between = rows.find((row) => row.pid === top.ppid);
const viaLaunchScript = !!between && between.ppid === panePid && isStagedLaunchScript(between);
if (top.pid === panePid) paneShape = "direct_native";
else if (root && top.ppid === panePid && isShellForeground(executableBase(root))) paneShape = "shell_backed";
else return { ok: false, code: "pane_shape_unknown", message: "The pane is neither a shell running Codex nor Codex itself; nothing was stopped." };
else if (root && (top.ppid === panePid || viaLaunchScript) && isShellForeground(executableBase(root))) paneShape = "shell_backed";
else return { ok: false, code: "pane_shape_unknown", message: "The pane is neither a shell running Codex (directly or through OpenRig's own launch script) nor Codex itself; nothing was stopped." };
// Where the relaunched Codex would run, and with which home.
let cwd: string;
@@ -727,6 +731,16 @@ function executableBase(row: NativeProcessRow): string {
return (name.split("/").pop() ?? name).replace(/^-/, "");
}
/** The shells `/bin/sh` can run as. macOS's /bin/sh is a shim that runs bash (or dash or zsh, if so
* configured), so the OS names the staged script's process after that shell, not sh. */
const SH_RUNS_AS = new Set(["sh", "bash", "dash", "zsh"]);
/** `/bin/sh <path>` running a launch script that OpenRig's tmux adapter staged, and nothing else. */
function isStagedLaunchScript(row: NativeProcessRow): boolean {
const argv = processArgv(row.command);
return SH_RUNS_AS.has(executableBase(row)) && argv.length === 2 && /^(?:\/bin\/)?sh$/.test(argv[0]!) && isStagedSendScriptPath(argv[1]!);
}
/** The Codex process and its Codex launcher ancestors, deepest first, up to the pane root. */
function codexChain(rows: NativeProcessRow[], deepest: NativeProcessRow, panePid: number): NativeProcessRow[] {
const byPid = new Map(rows.map((row) => [row.pid, row]));
@@ -0,0 +1,96 @@
// Desk-only comparison (never merged): one pod-aware Claude resume through the real adapter ->
// startup -> restore mapping, with mocked terminal effects, at v0.6.10 and at the 0.6.11 candidate.
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { SnapshotRepository } from "../src/domain/snapshot-repository.js";
import { CheckpointStore } from "../src/domain/checkpoint-store.js";
import { SnapshotCapture } from "../src/domain/snapshot-capture.js";
import { RigTeardownOrchestrator } from "../src/domain/rig-teardown.js";
import { NodeLauncher } from "../src/domain/node-launcher.js";
import { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
import { ClaudeCodeAdapter } from "../src/adapters/claude-code-adapter.js";
import { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
import { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
import { NativePermissionStore } from "../src/domain/native-permission-store.js";
import type { ClaudeManagedLaunch } from "../src/domain/claude-managed-launch.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
const token = "00000000-0000-4000-8000-000000000006";
const autoScreen = "Restored conversation\n────────────────\n❯ \n────────────────\n ⏵⏵ auto mode on (shift+tab to cycle) · ← for agents\n ● high · /effort\n";
const SCREENS: Record<string, string> = {
missing: `$ claude --resume ${token}\nNo conversation found with session ID: ${token}\nuser@host:~/project$ `,
"generic-exit": "$ claude --resume <token>\nError: simulated Claude exit (not a missing conversation)\nuser@host:~/project$ ",
"attention-control": autoScreen,
};
describe("F4 #1191 comparison", () => {
const dbs: ReturnType<typeof createFullTestDb>[] = [];
afterEach(() => { for (const db of dbs.splice(0)) db.close(); });
it.each(["missing", "generic-exit", "attention-control"])("%s", async (mode) => {
const db = createFullTestDb(); dbs.push(db);
const rigRepo = new RigRepository(db);
const sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
const snapshotRepo = new SnapshotRepository(db);
const checkpointStore = new CheckpointStore(db);
const snapshotCapture = new SnapshotCapture({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, checkpointStore });
const rig = rigRepo.createRig("restore-test");
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("restore-pod", rig.id, "Test");
const node = rigRepo.addNode(rig.id, "test.c", { runtime: "claude-code", podId: "restore-pod" });
new NativePermissionStore(db).write(node.id, { runtime: "claude-code", mode: "auto" }, "fixture", "retained posture");
const name = "test-c@restore-test";
const old = sessionRegistry.registerSession(node.id, name);
sessionRegistry.updateStatus(old.id, "running");
sessionRegistry.updateResumeToken(old.id, "claude_id", token, "scrape");
sessionRegistry.updateBinding(node.id, { tmuxSession: name, tmuxPane: "%old" });
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "claude-code");
let live = true;
const tmux = {
hasSession: vi.fn(async () => live),
probeSession: vi.fn(async () => ({ state: live ? "present" : "absent" })),
killSession: vi.fn(async () => { live = false; return { ok: true }; }),
createSession: vi.fn(async () => { live = true; return { ok: true }; }),
listSessions: vi.fn(async () => live ? [{ name }] : []),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async () => [{ id: "%new", index: 0, cwd: "/", width: 80, height: 24, active: true }]),
getPanePid: vi.fn(async () => 100),
getPaneCommand: vi.fn(async () => "bash"),
capturePaneContent: vi.fn(async () => SCREENS[mode]),
sendText: vi.fn(async () => ({ ok: true })),
sendShellCommand: vi.fn(async () => ({ ok: true })),
sendKeys: vi.fn(async () => ({ ok: true })),
} as unknown as TmuxAdapter;
const startedAt = "Thu Oct 1 05:53:16 2026";
const listProcesses = async () => [{ pid: 100, ppid: 1, pgid: 100, tpgid: 100, executableName: "bash", command: "-bash", startedAt }];
const down = await new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotCapture, tmuxAdapter: tmux }).teardown(rig.id);
expect(down.errors).toEqual([]);
const adapter = new ClaudeCodeAdapter({ tmux, listProcesses, sleep: async () => {},
claudeManagedLaunch: { prepare: async () => ({ command: (args: readonly string[]) => `claude ${args.join(" ")}`, assertCurrent: () => {}, configDir: "/fixture", executable: "/opt/claude.exe" }) } as unknown as ClaudeManagedLaunch,
fsOps: { exists: () => false, readFile: () => "", writeFile: () => {}, mkdirp: () => {}, copyFile: () => {} } });
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const restore = new RestoreOrchestrator({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo, snapshotCapture,
checkpointStore, nodeLauncher, tmuxAdapter: tmux, claudeResume: new ClaudeResumeAdapter(tmux),
codexResume: new CodexResumeAdapter(tmux), listProcesses });
vi.mocked(tmux.killSession).mockClear(); vi.mocked(tmux.createSession).mockClear();
const up = await restore.restore(down.snapshotId!, { adapters: { "claude-code": adapter } });
const rows = db.prepare("SELECT id, status, startup_status, resume_type, resume_token FROM sessions WHERE node_id = ? ORDER BY id").all(node.id);
const report = {
mode,
ok: up.ok,
rigResult: up.ok ? (up.result as { rigResult?: unknown }).rigResult ?? null : (up as { message?: string }).message,
node: up.ok ? up.result.nodes[0] : null,
createSessionCalls: vi.mocked(tmux.createSession).mock.calls.length,
killSessionCalls: vi.mocked(tmux.killSession).mock.calls.map((c) => c[0]),
sessionLiveAfter: live,
oldRow: db.prepare("SELECT status, resume_type, resume_token FROM sessions WHERE id = ?").get(old.id),
rows,
binding: sessionRegistry.getBindingForNode(node.id),
};
console.log("F4_REPORT " + JSON.stringify(report));
expect(up.ok).toBe(true);
});
});
@@ -93,7 +93,9 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
/** Each word single-quoted for the harness's sh, so tmux receives them as separate argv words. */
const argv = (...words: string[]) => words.map(q).join(" ");
async function setup(shape: "shell_backed" | "direct_native", opts: { running?: { networkOverride: boolean; daemonOptOut: boolean; addDirs: string[] }; quiet?: boolean; prelude?: string } = {}) {
async function setup(shape: "shell_backed" | "direct_native", opts: { running?: { networkOverride: boolean; daemonOptOut: boolean; addDirs: string[] }; quiet?: boolean; prelude?: string;
/** Shell-backed: type the launch the way OpenRig does (tmux adapter's staged `/bin/sh <script>`). */
staged?: boolean } = {}) {
// One seat at a time: end the previous test's session and wait for its stub to exit.
for (const name of sessions) await tmux(`kill-session -t ${q(name)}`).catch(() => {});
for (let i = 0; i < 50 && stubPids().length > 0; i++) await sleep(100);
@@ -141,8 +143,14 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
if (shape === "shell_backed") {
await tmux(`-f /dev/null new-session -d -s ${q(seat)} -x 120 -y 30 -c ${q(work)} ${env} ${argv(...cleanStart)}`);
await sleep(200);
await tmux(`send-keys -t ${q(seat)} -l -- ${q(`${opts.prelude ?? ""}${preflight} && ${built.command} || echo PREFLIGHT-REFUSED`)}`);
await tmux(`send-keys -t ${q(seat)} Enter`);
const launch = `${opts.prelude ?? ""}${preflight} && ${built.command} || echo PREFLIGHT-REFUSED`;
if (opts.staged) {
const sent = await realTmux.sendShellCommand(seat, launch);
if (!sent.ok) throw new Error(sent.message);
} else {
await tmux(`send-keys -t ${q(seat)} -l -- ${q(launch)}`);
await tmux(`send-keys -t ${q(seat)} Enter`);
}
} else {
const script = `${preflight} || { echo PREFLIGHT-REFUSED; exec sleep 30; }; exec ${built.command}`;
await tmux(`-f /dev/null new-session -d -s ${q(seat)} -x 120 -y 30 -c ${q(work)} ${env} ${argv(...cleanStart, "-c", script)}`);
@@ -195,6 +203,28 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
expect(verified.body).toMatchObject({ ok: true, outcome: "restarted_account_unverified", accountEvidence: { state: "not_requested" } });
}, 60_000);
it("a Codex launched the way OpenRig launches it (staged sh script), restarted twice in the same pane", async () => {
const { pane, post } = await setup("shell_backed", { staged: true });
const shell = await panePid(pane);
const [oldStub] = stubPids();
const parentOf = (pid: number) => Number(fs.readFileSync(`/proc/${pid}/stat`, "utf8").replace(/^.*\) /, "").split(" ")[1]);
const script = parentOf(oldStub!);
expect(script, "a staged script sits between the shell and Codex").not.toBe(shell);
expect(parentOf(script)).toBe(shell);
expect(fs.readFileSync(`/proc/${script}/cmdline`, "utf8")).toMatch(/openrig-tmux-send-\d+-[0-9a-f-]+\.txt/);
for (const round of [1, 2]) {
const plan = await post("restart-plan");
expect(plan.body, `round ${round}: ${JSON.stringify(plan.body)}`).toMatchObject({ ok: true, paneShape: "shell_backed" });
const result = await post("restart", { reason: `native harness staged round ${round}` });
expect(result.status, JSON.stringify(result.body)).toBe(200);
expect(result.body).toMatchObject({ outcome: "restarted_account_unverified", pane, paneShape: "shell_backed" });
for (const pid of stubPids()) pids.add(pid);
expect(stubPids(), `round ${round}: only the replacement stub runs`).toEqual([result.body.newProcess.pid]);
expect(await panePid(pane), "same pane shell").toBe(shell);
}
}, 90_000);
it("direct-native: respawned without -k, remain-on-exit put back after the verified replacement", async () => {
const { seat, pane, post } = await setup("direct_native");
const before = await panePid(pane);
@@ -206,11 +236,16 @@ describe.runIf(available())("in-place restart, native (private tmux socket, stub
for (const pid of stubPids()) pids.add(pid);
const after = await panePid(pane);
expect(after).not.toBe(before);
expect(stubPids(), "only the replacement stub runs").toEqual([after]);
// The replacement is the respawned pane's root, or its child where the image's sh doesn't exec a lone
// command (review-r2's dash): compare with the reported process, and tie it to the pane separately.
const replacement = result.body.newProcess.pid as number;
expect(stubPids(), "only the replacement stub runs").toEqual([replacement]);
const parent = Number(fs.readFileSync(`/proc/${replacement}/stat`, "utf8").replace(/^.*\) /, "").split(" ")[1]);
expect([replacement, parent], "the replacement is the respawned pane's root or its direct child").toContain(after);
// tmux resets the visible screen on respawn; the original's earlier output is kept in scrollback.
const screen = await tmux(`capture-pane -p -t ${q(pane)} -S -300`);
expect(screen, "the original's scrollback stays in the pane").toContain(`from pid ${before}`);
expect(screen).toContain(`stub conversation ${TOKEN} pid ${after}`);
expect(screen).toContain(`stub conversation ${TOKEN} pid ${replacement}`);
expect((await tmux(`show-options -p -v -t ${q(pane)} remain-on-exit`)).trim(), "no pane-local setting left behind").toBe("");
expect((await tmux(`list-panes -t ${q(seat)} -F '#{pane_id}'`)).trim()).toBe(pane);
}, 60_000);
@@ -1,5 +1,6 @@
// OPR.0.7.0.5 — in-place restart of one Codex seat: every gate and every outcome, driven through the
// public SeatLifecycleService surface against a fake tmux, process table, Codex launcher and OS reads.
import { defaultTmuxFileOps, isStagedSendScriptPath } from "../src/adapters/tmux.js";
import { describe, it, expect, afterEach } from "vitest";
import type Database from "better-sqlite3";
import { createFullTestDb } from "./helpers/test-app.js";
@@ -26,6 +27,11 @@ interface WorldOptions {
shape?: Shape;
runningArgs?: string;
savedModel?: string | null;
/** Shell-backed only: the command line of a process between the pane shell and Codex (OpenRig's
* staged launch script, or something else). */
between?: string;
/** The OS's name for that process (ps ucomm); defaults to argv[0]'s basename. */
betweenName?: string;
activity?: ArbitratedSeatState | null;
launch?: HarnessLaunchResult;
codexIgnoresSignals?: boolean;
@@ -76,7 +82,10 @@ function world(options: WorldOptions = {}) {
const runningArgs = options.runningArgs ?? `-m gpt-5.5 -s workspace-write resume ${TOKEN}`;
let rows: NativeProcessRow[] = shape === "shell_backed"
? [shellRow(100, 200), codexRow(200, 100, runningArgs)]
? options.between
? [shellRow(100, 150), { pid: 150, ppid: 100, pgid: 150, tpgid: 150, executableName: options.betweenName ?? options.between.split(" ")[0]!.split("/").pop()!, command: options.between, startedAt: LSTART },
codexRow(200, 150, runningArgs, LSTART, 150)]
: [shellRow(100, 200), codexRow(200, 100, runningArgs)]
: [codexRow(100, 1, runningArgs, LSTART, 100)];
let paneCommand = "codex";
let dead = false;
@@ -109,7 +118,8 @@ function world(options: WorldOptions = {}) {
signalPid: async (pid: number, signal: string): Promise<TmuxResult> => {
calls.push(`signalPid ${pid} ${signal}`);
maybeThrow("signalPid");
if (!options.codexIgnoresSignals) { rows = rows.filter((row) => row.executableName !== "codex"); paneCommand = "bash"; }
// The launch script ends with Codex, so its sh exits too.
if (!options.codexIgnoresSignals) { rows = rows.filter((row) => row.executableName !== "codex" && row.pid !== 150); paneCommand = "bash"; }
return { ok: true };
},
signalPaneProcess: async (_pane: string, signal: string): Promise<TmuxResult> => {
@@ -239,6 +249,34 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
expect(await cwd.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ code: "settings_drift", detail: { differs: ["cwd"] } });
});
it("accepts a Codex launched through OpenRig's staged launch script, and only that script, between shell and Codex", async () => {
const staged = "/bin/sh /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt";
const w = world({ between: staged });
expect(await w.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ ok: true, paneShape: "shell_backed" });
// macOS (measured on Build): /bin/sh is a shim that runs bash, so ps names the process bash.
const mac = world({ between: "/bin/sh /var/folders/7x/abc123/T/openrig-tmux-send-4242-5b249a92-40d6-40c4-b424-57b3b6980529.txt", betweenName: "bash" });
expect(await mac.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ ok: true, paneShape: "shell_backed" });
// The real generator's names are what the matcher accepts.
const generated = defaultTmuxFileOps().tmpName();
expect(isStagedSendScriptPath(generated), generated).toBe(true);
const done = await w.service.restartInPlace({ seatRef: SEAT, reason: "new account" });
expect(done).toMatchObject({ ok: true, outcome: "restarted_account_unverified", paneShape: "shell_backed" });
expect(w.calls).toContain("signalPid 200 TERM");
for (const other of [
"/bin/sh /home/u/run-codex.sh",
"/bin/sh /tmp/openrig-tmux-send-1.txt",
"/bin/sh -c /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
"/usr/bin/script -q /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
"/bin/sh tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
"/bin/sh /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt extra",
"/bin/bash /tmp/openrig-tmux-send-1128548-5b249a92-40d6-40c4-b424-57b3b6980529.txt",
]) {
const refused = world({ between: other });
expect(await refused.service.restartInPlace({ seatRef: SEAT, plan: true }), other).toMatchObject({ ok: false, code: "pane_shape_unknown" });
expect(refused.calls, other).toEqual([]);
}
});
it("refuses when the running Codex is on another conversation, or none can be proven", async () => {
const other = world({ runningArgs: "-m gpt-5.5 -s workspace-write resume 019a-other" });
expect(await other.service.restartInPlace({ seatRef: SEAT, plan: true })).toMatchObject({ ok: false, code: "resume_binding_unverified" });