fix(terminal): preserve create receipts and shorten viewer startup input

This commit is contained in:
dev-guard committed 2026-11-07 11:24:22 +00:00
1 parent 0df2d981a9
commit d4a958509f
4 files changed
+95 -19

No files matched your search

@@ -109,7 +109,8 @@ export class HerdrFollowView {
const reply = body(await this.deps.run(["workspace", "create", "--cwd", state.cwd,
"--label", view.task.name, "--no-focus"]));
const made = pane(reply.root_pane);
if (!made || !sameHerdrPath(made.cwd, state.cwd)) throw new Error("Workspace creation returned no attributable pane/terminal pair.");
if (!made) throw new Error("Workspace creation returned no pane/terminal identity.");
// A successful creation reply binds these IDs even if the rc already ran cd.
state.pane = made; state.phase = "created"; save();
}
if (!state.pane) {
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createHash, randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, rmdirSync, writeFileSync, realpathSync } from "node:fs";
import { mkdirSync, readFileSync, renameSync, rmdirSync, writeFileSync, realpathSync, lstatSync, unlinkSync } from "node:fs";
import path from "node:path";
import { getDefaultOpenRigPath } from "../../openrig-compat.js";
import { resolveOnPath } from "../../adapters/tmux-exec.js";
@@ -21,7 +21,7 @@ export interface ViewerPane { workspace_id: string; pane_id: string; terminal_id
type Pane = ViewerPane;
interface Entry {
cwd: string; label: string; command: string; argv: string[]; readOnly: boolean;
phase: "creating" | "created" | "starting"; pane?: Pane;
phase: "creating" | "created" | "starting"; pane?: Pane; scriptHash?: string;
}
interface Journal { version: 1; entries: Record<string, Entry> }
const runFile = promisify(execFile);
@@ -86,11 +86,46 @@ export async function findAttributedViewerPanes(run: HerdrViewerRun, cwd: string
return found;
}
function ownedViewerDirectory(entry: Entry, root: string, endpoint: string): string {
if (!/^openrig-viewer-[0-9a-f-]{36}$/.test(entry.label)
|| entry.cwd !== path.join(root, endpoint, entry.label.slice("openrig-viewer-".length))) {
throw new Error("Viewer provenance has no owned attribution directory.");
}
return entry.cwd;
}
function prepareViewerCommand(entry: Entry, root: string, endpoint: string): string {
const file = path.join(ownedViewerDirectory(entry, root, endpoint), "attach.sh");
const source = `exec ${entry.command}\n`;
const hash = createHash("sha256").update(source).digest("hex");
try { writeFileSync(file, source, { mode: 0o600, flag: "wx" }); }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !lstatSync(file).isFile()
|| lstatSync(file).isSymbolicLink() || createHash("sha256").update(readFileSync(file)).digest("hex") !== hash) throw error;
}
entry.scriptHash = hash;
// Keep terminal input short even while the login shell uses canonical mode.
return `/bin/sh '${file.replace(/'/g, `'"'"'`)}'`;
}
function retireViewerDirectory(entry: Entry, root: string, endpoint: string): void {
const cwd = ownedViewerDirectory(entry, root, endpoint), file = path.join(cwd, "attach.sh");
if (entry.scriptHash) {
try {
if (!lstatSync(file).isFile() || lstatSync(file).isSymbolicLink()
|| createHash("sha256").update(readFileSync(file)).digest("hex") !== entry.scriptHash) {
throw new Error("The owned viewer script changed; it was preserved.");
}
unlinkSync(file);
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
}
try { rmdirSync(cwd); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
}
/** One owned viewer per seat/endpoint. The journal is presentation provenance,
* not task membership. It is written BEFORE each possible effect so neither a
* lost reply nor a daemon restart authorizes a second create/run. Labels alone
* never establish ownership; creation attribution also needs our private cwd,
* exact returned pane/terminal pair, and the actual foreground attach argv.
* never establish ownership: a received create reply binds the returned IDs;
* lost replies require unique private-cwd recovery. The foreground attach
* argv and current seat binding are always confirmed before reuse.
*/
export class HerdrViewer {
private pending = Promise.resolve();
@@ -174,7 +209,8 @@ export class HerdrViewer {
}
const reply = body(raw);
const made = pane(reply.root_pane);
if (!made || !sameHerdrPath(made.cwd, cwd)) return unconfirmed("Create returned no attributable pane/terminal pair; its effect remains uncertain.");
if (!made) return unconfirmed("Create returned no pane/terminal identity; its effect remains uncertain.");
// These IDs came from this creation call. Its cwd may already reflect an rc cd.
entry.pane = made; entry.phase = "created"; save(); created = true;
}
if (!entry.pane) {
@@ -193,14 +229,9 @@ export class HerdrViewer {
if (!provision || (await this.ownedPanes(entry)).length !== 0) {
return unconfirmed("Viewer identity changed or disappeared; an enumerable viewer cannot be replaced.");
}
// Only a complete empty readback retires provenance. Remove our empty
// attribution directory, never provider panes or arbitrary journal paths.
if (!/^openrig-viewer-[0-9a-f-]{36}$/.test(entry.label)
|| entry.cwd !== path.join(this.root, endpoint, entry.label.slice("openrig-viewer-".length))) {
return unconfirmed("Absent viewer provenance has no owned attribution directory.");
}
try { rmdirSync(entry.cwd); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
// Only complete empty readback retires provenance. Remove the exact owned
// script and directory; never provider panes or arbitrary journal paths.
retireViewerDirectory(entry, this.root, endpoint);
delete journal.entries[id]; save();
return { ok: false, code: "viewer_not_provisioned", error: "The old viewer is confirmed absent; ensure again to create a verified replacement." };
}
@@ -209,9 +240,10 @@ export class HerdrViewer {
if (!await stillCurrent()) return unconfirmed("Task membership changed before the viewer process started.");
const beforeRun = await this.inspect(target.attachArgv);
if (beforeRun.nodeId !== key.nodeId || beforeRun.sessionId !== binding.sessionId) return unconfirmed("The seat binding changed before the viewer process started.");
const command = prepareViewerCommand(entry, this.root, endpoint);
entry.phase = "starting"; save();
// pane run rc0/empty stdout is not proof. It is sent ONCE then checked.
await this.run(["pane", "run", current.pane_id, entry.command]);
await this.run(["pane", "run", current.pane_id, command]);
}
const process = body(await this.run(["pane", "process-info", "--pane", current.pane_id]));
const info = process.process_info as { foreground_processes?: Array<{ argv?: unknown }> } | undefined;
@@ -258,7 +290,7 @@ export class HerdrViewer {
|| entry.pane.workspace_id !== expected.workspaceId || !/^openrig-viewer-[0-9a-f-]{36}$/.test(entry.label)
|| entry.cwd !== path.join(this.root, endpoint, entry.label.slice("openrig-viewer-".length))) return false;
if ((await this.ownedPanes(entry)).length !== 0) return false;
try { rmdirSync(entry.cwd); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return false; }
retireViewerDirectory(entry, this.root, endpoint);
delete journal.entries[id];
const temporary = `${file}.${randomUUID()}.tmp`;
writeFileSync(temporary, JSON.stringify(journal) + "\n", { mode: 0o600 }); renameSync(temporary, file);
+12 -2
View File
@@ -31,6 +31,7 @@ function fixture(platform: NodeJS.Platform = "linux", status = {}) {
const frames = new Map<string, ComposerSnapshot | null>();
let transform = (view: ComposedView) => view;
let reportedCwd: string | null | undefined;
let changedCreateCwd = false;
let lateFocus: { seat: string; reads: number } | undefined;
let clock = 100, lostCreate = false, lostClose = false, readsFailed = false, hideViewers = false, failedViewerRun = false, failedProcessRead = false;
const run = async (args: string[]) => {
@@ -42,6 +43,7 @@ function fixture(platform: NodeJS.Platform = "linux", status = {}) {
const id = `p${calls.length}`;
const made = { pane_id: id, workspace_id: "w1", terminal_id: `term-${id}`, cwd: args[args.indexOf("--cwd") + 1]!,
focused: panes.size === 0, argv: ["/bin/bash"] };
if (changedCreateCwd) made.cwd = tmpdir();
panes.set(id, made);
if (lostCreate) { lostCreate = false; throw new Error("lost create response"); }
return JSON.stringify({ result: { [command === "workspace create" ? "root_pane" : "pane"]: made } });
@@ -60,11 +62,12 @@ function fixture(platform: NodeJS.Platform = "linux", status = {}) {
if (command === "pane run") {
const selected = panes.get(args[2]!)!;
if (args[3]!.startsWith("/usr/bin/tail")) {
selected.argv = ["/usr/bin/tail", "-n", "30", "-F", path.join(selected.cwd, "..", "events.log")];
selected.argv = ["/usr/bin/tail", "-n", "30", "-F", args[3]!.match(/-F '([^']+)'$/)![1]!];
} else {
if (failedViewerRun) { failedViewerRun = false; throw new Error("first attach dispatch failed"); }
// The shared composer produces one wrapper per actual seat.
const seat = ["a", "b", "c", "d"].find(s => args[3]!.includes(`'${s}'`))!;
const command = args[3]!.startsWith("/bin/sh ") ? readFileSync(args[3]!.slice(9, -1), "utf8") : args[3]!;
const seat = ["a", "b", "c", "d"].find(s => command.includes(`'${s}'`))!;
selected.argv = ["/usr/bin/tmux", "-S", "/tmp/follow-seats.sock", "attach", "-f", "ignore-size", "-t", `$${seat}`];
}
return "";
@@ -101,6 +104,7 @@ function fixture(platform: NodeJS.Platform = "linux", status = {}) {
const exitViewer = (seat: string) => { [...panes.values()].find(p => p.argv.at(-1) === `$${seat}`)!.argv = ["/bin/bash"]; };
return { follow, create, panes, calls, rpc, inputs, frames, exitViewer, journal,
reportCwd: (cwd: string | null) => { reportedCwd = cwd; },
changeCreateCwd: () => { changedCreateCwd = true; },
failViewerRun: () => { failedViewerRun = true; },
failProcessRead: () => { failedProcessRead = true; },
confirmViewer: (seat: string) => { const entry = JSON.parse(readFileSync(journal(), "utf8")).entries[JSON.stringify(["self", seat])]; panes.get(entry.pane.pane_id)!.argv = ["/usr/bin/tmux", "-S", "/tmp/follow-seats.sock", "attach", "-f", "ignore-size", "-t", `$${seat}`]; },
@@ -113,6 +117,12 @@ function fixture(platform: NodeJS.Platform = "linux", status = {}) {
}
describe("stable task-following workspace", () => {
it("keeps the received strip creation IDs even if its rc already changed cwd", async () => {
const f = fixture(); f.changeCreateCwd();
expect(await f.follow(["a"])).toMatchObject({ ok: true, opened: ["a"] });
expect(f.calls.filter(args => args[1] === "create")).toHaveLength(1);
});
it("refuses a new incompatible protocol before effects but keeps an existing canvas on a protocol change", async () => {
const unsupported = fixture("linux", { protocol: 23 });
expect(await unsupported.follow()).toMatchObject({ ok: false, code: "follow_unsupported", following: { enabled: false } });
+34 -1
View File
@@ -23,7 +23,7 @@ function fixture() {
let label = "", running = false, uncertainCreate = false, uncertainRun = false, idless = false;
let gone = false, enumerable = true, unreadable = false, attachFlags = ["-f", "ignore-size"];
let workspaceInventory: unknown[] | undefined, paneInventory: Record<string, unknown> | undefined;
let canonicalCreateCwd = false;
let canonicalCreateCwd = false, changedCreateCwd = false, canonicalLimit = 0;
const inspect = async () => ({ sessionId: "$1", nodeId: key.nodeId });
const run: HerdrViewerRun = async args => {
calls.push(args);
@@ -33,6 +33,7 @@ function fixture() {
gone = false; enumerable = true;
ids.cwd = args[args.indexOf("--cwd") + 1]!; label = args[args.indexOf("--label") + 1]!;
if (canonicalCreateCwd) ids.cwd = realpathSync(ids.cwd);
if (changedCreateCwd) ids.cwd = tmpdir();
if (uncertainCreate) { uncertainCreate = false; throw new Error("lost response"); }
return JSON.stringify({ result: { root_pane: idless ? {} : ids } });
}
@@ -43,6 +44,7 @@ function fixture() {
if (command === "pane list") return JSON.stringify({ result: paneInventory ? paneInventory[args[args.indexOf("--workspace") + 1]!] : { panes: [ids] } });
if (command === "pane get") { if (gone) throw new Error("pane not found"); return JSON.stringify({ result: { pane: ids } }); }
if (command === "pane run") {
if (canonicalLimit && Buffer.byteLength(args[3]!) >= canonicalLimit) throw new Error("pane_send_failed: canonical mode line limit");
running = true;
if (uncertainRun) { uncertainRun = false; throw new Error("lost run response"); }
return "";
@@ -54,6 +56,8 @@ function fixture() {
const viewer = (viewerRoot = root, platform: NodeJS.Platform = "linux", status = {}) => new HerdrViewer(socket, async args => args[0] === "status" ? JSON.stringify({ running: true, version: "0.9.5", protocol: 22, socket, ...status }) : run(args), platform, viewerRoot, inspect);
return { root, target, calls, viewer, ids,
canonicalizeCreateCwd: () => { canonicalCreateCwd = true; },
changeCreateCwd: () => { changedCreateCwd = true; },
limitCanonicalInput: (bytes: number) => { canonicalLimit = bytes; },
lose: (listed: boolean, malformed = false) => { gone = true; enumerable = listed; unreadable = malformed; },
loseChanged: () => { gone = false; enumerable = false; },
setEnumeration: (workspaces: unknown[], panes: Record<string, unknown>) => { workspaceInventory = workspaces; paneInventory = panes; },
@@ -62,6 +66,28 @@ function fixture() {
}
describe("owned Herdr viewer", () => {
it("uses received creation IDs when the shell changed cwd before the reply", async () => {
const f = fixture(); f.changeCreateCwd();
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true, created: true });
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true, created: false });
expect(f.calls.filter(args => args[1] === "create")).toHaveLength(1);
});
it("keeps a lost creation reply plus changed cwd uncertain without repeating create or run", async () => {
const f = fixture(); f.changeCreateCwd(); f.setCreateLost();
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: false });
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: false });
expect(f.calls.filter(args => args[1] === "create")).toHaveLength(1);
expect(f.calls.filter(args => args[1] === "run")).toHaveLength(0);
});
it("starts the exact attach through a short owned script under a canonical-mode line limit", async () => {
const f = fixture(); f.limitCanonicalInput(1024);
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true });
const command = f.calls.find(args => args[1] === "run")![3]!;
expect(Buffer.byteLength(command)).toBeLessThan(1024);
expect(command).toContain("/bin/sh ");
expect(readFileSync(path.join(f.ids.cwd, "attach.sh"), "utf8")).toBe(`exec ${f.target.paneCommand}\n`);
});
it.each(["linux", "darwin"] as const)("admits protocol 22 on %s independently of the release label", async platform => {
const f = fixture();
expect(await f.viewer(f.root, platform, { version: "0.9.6" }).ensure(key, f.target)).toMatchObject({ ok: true });
@@ -76,6 +102,13 @@ describe("owned Herdr viewer", () => {
const v = new HerdrViewer(alias, run, "darwin", f.root, async () => ({ sessionId: "$1", nodeId: key.nodeId }));
expect(await v.lookup(key, f.target)).toMatchObject({ code: "viewer_not_provisioned" });
});
it("preserves a changed script when its viewer is confirmed absent", async () => {
const f = fixture(); expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: true });
const file = path.join(f.ids.cwd, "attach.sh"); writeFileSync(file, "user change\n"); f.lose(false);
expect(await f.viewer().ensure(key, f.target)).toMatchObject({ ok: false, code: "viewer_unconfirmed" });
expect(readFileSync(file, "utf8")).toBe("user change\n");
expect(f.calls.filter(args => args[1] === "create")).toHaveLength(1);
});
it("keeps a known viewer after its cwd changes and still checks its process", async () => {
const f = fixture(), v = f.viewer();
expect(await v.ensure(key, f.target)).toMatchObject({ ok: true, created: true });