mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
Compare commits
23
Commits
main
...
dd/scratch-1100
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d95f9c8cb6 | ||
|
|
760024dd7c | ||
|
|
65e0696892 | ||
|
|
1d92174e26 | ||
|
|
2493794d1b | ||
|
|
a24237e6d6 | ||
|
|
f02dbd49a0 | ||
|
|
aacaa8159e | ||
|
|
b07f76b768 | ||
|
|
2abe5d4d4a | ||
|
|
46059e6267 | ||
|
|
6ad55f3bb3 | ||
|
|
64c07ff3ff | ||
|
|
9d7d40f218 | ||
|
|
aa379f44b8 | ||
|
|
cb6087c1ce | ||
|
|
12b9a842f3 | ||
|
|
6da8f5e903 | ||
|
|
f10e343eb6 | ||
|
|
88cadcbd74 | ||
|
|
061822c816 | ||
|
|
aef37577ed | ||
|
|
48af9fa8c9 |
No files matched your search
@@ -519,6 +519,8 @@ All three must be present together. Any partial combination is a validation erro
|
||||
|
||||
A terminal seat no longer waits for a `--fresh` decision just because it has no resume token: on restore it relaunches fresh and re-runs its startup actions. The runtime, identity and replay prerequisites still apply. A runtime mismatch still waits for a decision, an ambiguous occupant still fails, and a missing required replay file still blocks.
|
||||
|
||||
`rig send` to a terminal node types the exact text, without the From/To envelope that agent seats get, because the shell would run the envelope lines. The sender's output says so.
|
||||
|
||||
### agent_ref Rules
|
||||
|
||||
- Must start with `local:` or `path:`
|
||||
|
||||
@@ -306,7 +306,9 @@ The extra reads add up to about 500 ms before Enter and 500 ms before the reply
|
||||
A mid-task/busy target now sends-with-advisory by default (busy is not a block);
|
||||
--force is a back-compat no-op and never bypasses the interactive-prompt/permission
|
||||
guard. Use --raw to send exact text/keystrokes
|
||||
without the From/To envelope (e.g. a slash command); it is still guarded. Use
|
||||
without the From/To envelope (e.g. a slash command); it is still guarded. A
|
||||
terminal seat (runtime: terminal) always gets the exact text, because its shell
|
||||
would run the envelope lines; the output says so. Use
|
||||
--dangerously-interact --reason "<why>" to DELIBERATELY drive a prompt (select an
|
||||
option or approve a permission) — the only override of the prompt guard; it
|
||||
implies --raw and is audit-logged. The answer is typed as keystrokes: letters may
|
||||
@@ -518,7 +520,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
|
||||
let res: { status: number; data: Record<string, unknown> };
|
||||
try {
|
||||
res = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session, text: outboundText, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
session, text: outboundText, exactText: raw ? undefined : payload, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
dangerouslyInteract: opts.dangerouslyInteract, typeWithoutChecking: opts.typeWithoutChecking, reason: opts.reason, actorSession: senderSession ?? null,
|
||||
...(opts.urgent ? { urgent: true } : {}),
|
||||
}, transportRequestOptions(waitForIdleMs));
|
||||
@@ -550,7 +552,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
|
||||
// return alone when --verify asked for consumption.
|
||||
let effect: EffectCheck | undefined;
|
||||
if (opts.verify && res.status < 400) {
|
||||
effect = await classifyDeliveryEffect(client, session, stagedIdentityFor(payload, outboundText), waitForIdleMs, res.data["promptInteraction"], opts.dangerouslyInteract);
|
||||
effect = await classifyDeliveryEffect(client, session, stagedIdentityFor(payload, res.data["envelopeOmitted"] ? payload : outboundText), waitForIdleMs, res.data["promptInteraction"], opts.dangerouslyInteract);
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
@@ -580,6 +582,7 @@ agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
|
||||
}
|
||||
|
||||
console.log(`Sent to ${session}`);
|
||||
if (res.data["envelopeOmitted"]) console.log(exactTextNote(session));
|
||||
printCompactionNotice(console.log);
|
||||
// OPR.0.4.3.28 correction — an `unknown`-telemetry send now PROCEEDS with a non-blocking
|
||||
// advisory (was a fail-closed refusal). Surface it on the human output, not only in --json.
|
||||
@@ -644,6 +647,10 @@ interface StagedIdentity {
|
||||
payloadHead: string;
|
||||
}
|
||||
|
||||
function exactTextNote(session: string): string {
|
||||
return `Note: sent as exact text, without the From/To envelope, because ${session} is a terminal seat.`;
|
||||
}
|
||||
|
||||
function stagedIdentityFor(payload: string, expectedStagedText: string): StagedIdentity {
|
||||
return {
|
||||
expectedStagedText,
|
||||
@@ -907,7 +914,7 @@ async function runHttpHostSend(
|
||||
const outboundText = raw ? text : wrapSendBody(originSender, session, text, { stampISO: new Date().toISOString() });
|
||||
|
||||
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/send", {
|
||||
session, text: outboundText, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
session, text: outboundText, exactText: raw ? undefined : text, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
dangerouslyInteract: opts.dangerouslyInteract, typeWithoutChecking: opts.typeWithoutChecking, reason: opts.reason, actorSession: senderSession ?? null,
|
||||
...(opts.urgent ? { urgent: true } : {}),
|
||||
}, deps, { timeoutMs: (waitForIdleMs ?? 0) + CROSS_HOST_SEND_TIMEOUT_MS });
|
||||
@@ -952,6 +959,7 @@ async function runHttpHostSend(
|
||||
return;
|
||||
}
|
||||
console.log(`Sent to ${session}`);
|
||||
if (data["envelopeOmitted"]) console.log(exactTextNote(session));
|
||||
const advisory = data["warning"] as string | undefined;
|
||||
if (advisory) {
|
||||
console.log(`Advisory: ${advisory}`);
|
||||
@@ -1101,7 +1109,7 @@ async function runFanOutSend(params: {
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; managedCompaction?: { notice?: unknown }; existingInput?: unknown }>) ?? [];
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string; outcome?: string; envelopeOmitted?: boolean; managedCompaction?: { notice?: unknown }; existingInput?: unknown }>) ?? [];
|
||||
// Context beside a recipient's own result: it was in a managed compaction or restore when the send ran.
|
||||
const printNotice = (r: (typeof results)[number]) => {
|
||||
if (typeof r.managedCompaction?.notice === "string") console.log(`Notice: ${r.managedCompaction.notice}`);
|
||||
@@ -1127,7 +1135,7 @@ async function runFanOutSend(params: {
|
||||
} else if (ec && !ec.checked) {
|
||||
console.log(`${r.sessionName}: sent (effect UNCHECKED: ${ec.why} — transport verdict only)`);
|
||||
} else {
|
||||
console.log(`${r.sessionName}: sent`);
|
||||
console.log(`${r.sessionName}: sent${r.envelopeOmitted ? " as exact text, without the From/To envelope (terminal seat)" : ""}`);
|
||||
}
|
||||
printNotice(r);
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ describe("send --host (http branch)", () => {
|
||||
expect(body.session).toBe("dev-impl@my-rig"); // BR-1: never member@rig@host
|
||||
expect(String(body.text)).toContain("To: dev-impl@my-rig");
|
||||
expect(String(body.text)).toContain("hello");
|
||||
expect(body.exactText).toBe("hello"); // the remote daemon types this if the seat is a terminal
|
||||
expect(call.options?.headers?.Authorization).toBe("Bearer test-token");
|
||||
expect(captured.stdoutLines).toContain("[via host=vps-b (http://vps-b:7433)]");
|
||||
expect(captured.stdoutLines).toContain("Sent to dev-impl@my-rig");
|
||||
|
||||
@@ -232,6 +232,47 @@ describe("Send CLI", () => {
|
||||
expect(logs.join("\n")).toContain("Sent to dev-impl@my-rig");
|
||||
});
|
||||
|
||||
it("a terminal seat: sends exactText beside the envelope, prints the note, and --verify checks the exact text", async () => {
|
||||
const posts: Array<{ path: string; body: Record<string, unknown> }> = [];
|
||||
let captures = 0;
|
||||
const client = {
|
||||
post: async (path: string, body: Record<string, unknown>) => {
|
||||
posts.push({ path, body });
|
||||
if (path === "/api/transport/capture") {
|
||||
captures += 1;
|
||||
return { status: 200, data: { ok: true, content: captures === 1 ? "❯ make test" : "❯ " } };
|
||||
}
|
||||
if (body["submitOnly"]) return { status: 200, data: { ok: true, submitOnly: true } };
|
||||
return { status: 200, data: { ok: true, sessionName: "infra-ui@term-rig", verified: true, outcome: "delivered", envelopeOmitted: true } };
|
||||
},
|
||||
} as unknown as DaemonClient;
|
||||
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd({ ...runningDeps(port), clientFactory: () => client }).parseAsync([
|
||||
"node", "rig", "send", "infra-ui@term-rig", "make test", "--verify",
|
||||
]);
|
||||
});
|
||||
|
||||
const sends = posts.filter((p) => p.path === "/api/transport/send");
|
||||
// An older daemon ignores exactText and still types the enveloped text.
|
||||
expect(sends[0]!.body["text"]).toContain("From: sender@my-rig");
|
||||
expect(sends[0]!.body["exactText"]).toBe("make test");
|
||||
// The staged check and its one guarded Enter look for what the terminal seat was given.
|
||||
const submit = sends.find((p) => p.body["submitOnly"]);
|
||||
expect(submit?.body["expectedStagedText"]).toBe("make test");
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("Sent to infra-ui@term-rig");
|
||||
expect(output).toContain("Note: sent as exact text, without the From/To envelope, because infra-ui@term-rig is a terminal seat.");
|
||||
});
|
||||
|
||||
it("--raw sends no exactText", async () => {
|
||||
await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", "/help", "--raw"]);
|
||||
});
|
||||
expect(lastSendBody!["text"]).toBe("/help");
|
||||
expect(lastSendBody!["exactText"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["", " \t\n"])("refuses an empty or whitespace-only direct message before transport", async (message) => {
|
||||
const { logs, exitCode } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "send", "dev-impl@my-rig", message]);
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { QueueWakeFacts } from "./draft-aware-delivery.js";
|
||||
import { derivePickup, type PickupReceipt } from "./queue-pickup.js";
|
||||
import { lastMeaningfulTransition, readWaitingView, type WaitingView, type WaitingActivityReader } from "./queue-waiting.js";
|
||||
import { wrapPaneEnvelope } from "../lib/pane-envelope.js";
|
||||
import { isBoundTerminalSeat } from "./terminal-seat.js";
|
||||
import { renderQueueHandoffNudge } from "./queue-nudge-text.js";
|
||||
import { getSelfHostId } from "./hosts/fanout-contract.js";
|
||||
import { parseSessionName, isHumanSeatSessionRef } from "./session-name.js";
|
||||
@@ -1596,6 +1597,16 @@ export class QueueRepository {
|
||||
if (destClass.class === "unroutable") {
|
||||
return { classified: "failed", nudgeResult: destClass.teaching };
|
||||
}
|
||||
// A terminal seat runs a shell or a TUI, not an agent. Typed wake text would run there as a
|
||||
// command line (it carries the row's summary), and nothing there can claim the row. So nothing
|
||||
// is typed; the row stays queued and visible. `failed:` keeps it on the wake ladder, whose
|
||||
// retries come back here and type nothing before it escalates.
|
||||
if (isBoundTerminalSeat(this.db, destinationSession)) {
|
||||
return {
|
||||
classified: "failed",
|
||||
nudgeResult: `failed:terminal-seat: '${destinationSession}' is a terminal seat (runtime: terminal), not an agent, so no wake was typed into it and nothing there can claim this row. The row stays queued; route it to an agent seat.`,
|
||||
};
|
||||
}
|
||||
const stampISO = new Date().toISOString();
|
||||
let text: string;
|
||||
if (prebuiltText !== undefined) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import type { AgentActivity } from "./types.js";
|
||||
import { DeliveryGuardError, type AdmissionInput, type GuardTarget, type SeatDeliveryGuard } from "./seat-delivery-guard.js";
|
||||
import { withUrgentLine } from "./urgent-line.js";
|
||||
import { wrapPaneEnvelope, appendDeliveredSegment, type EnvelopeScope } from "../lib/pane-envelope.js";
|
||||
import { isBoundTerminalSeat } from "./terminal-seat.js";
|
||||
import { getSelfHostId } from "./hosts/fanout-contract.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
import { isShellForeground } from "./shell-classifier.js";
|
||||
@@ -1640,6 +1641,13 @@ export interface SendOpts {
|
||||
* renders as "[Pasted text #N +X lines]"; X must match this count for the placeholder to count
|
||||
* as evidence of THIS piece. */
|
||||
expectedStagedLineCount?: number;
|
||||
/** The text without the From/To envelope. When set and the recipient is a terminal seat
|
||||
* (`runtime: terminal`), send() types this instead of `text`, so the shell never runs the
|
||||
* envelope lines. Agent recipients keep `text`. */
|
||||
exactText?: string;
|
||||
/** Internal: send() is typing `exactText` to a terminal seat, so its bytes stay exactly as given: no urgent line, which
|
||||
* the shell would run. An urgent mark still admits the input and stays in the result. Never accepted from HTTP. */
|
||||
typedExactly?: true;
|
||||
}
|
||||
|
||||
const CALLER_CANCELLED = "caller_cancelled";
|
||||
@@ -1717,6 +1725,8 @@ export interface SendResult {
|
||||
waitedMs?: number;
|
||||
attempts?: number;
|
||||
sent?: boolean;
|
||||
/** The recipient is a terminal seat, so `exactText` was typed without the From/To envelope. */
|
||||
envelopeOmitted?: true;
|
||||
/** Context, not a status: the seat was in a managed compaction or restore OpenRig had recorded when this send ran. The
|
||||
* send's own result (ok, outcome, error) is unchanged by it, and it never says the restore finished. */
|
||||
managedCompaction?: { stage: string; observed: "before_send" | "after_send"; notice: string };
|
||||
@@ -2205,6 +2215,15 @@ export class SessionTransport {
|
||||
* send, add a notice saying so. The notice is context beside the send's own result, which it never changes.
|
||||
*/
|
||||
async send(sessionName: string, text: string, opts?: SendOpts): Promise<SendResult> {
|
||||
// A terminal seat's shell would run the envelope lines as commands. Swap before the guard,
|
||||
// whose outbox body, hash and delivery-ID identity all key on the text actually typed.
|
||||
// Without the envelope there's no Sent: line, so stampISO is dropped too: the delivered-latency
|
||||
// segment must never edit a line of the exact text.
|
||||
if (opts?.exactText !== undefined && !opts.dangerouslyInteract) {
|
||||
const { exactText, ...rest } = opts;
|
||||
if (!isBoundTerminalSeat(this.db, sessionName)) return this.send(sessionName, text, rest);
|
||||
return { ...(await this.send(sessionName, exactText, { ...rest, stampISO: undefined, typedExactly: true })), envelopeOmitted: true };
|
||||
}
|
||||
const observe = () => { try { return this.compactionObserver?.(sessionName) ?? null; } catch { return null; } };
|
||||
const before = observe();
|
||||
const result = await this.sendWithoutCompactionNotice(sessionName, text, opts);
|
||||
@@ -2220,8 +2239,9 @@ export class SessionTransport {
|
||||
// Shown on delivery, and kept with a held message: who marked it urgent.
|
||||
console.info(`[transport] urgent send to ${sessionName}, marked by ${opts.urgent.by}`);
|
||||
// An explicit prompt answer is typed as keys, and a submit-only retry types no text at all: the label's letters would
|
||||
// act as keystrokes (a menu shortcut, say). Their bytes stay exactly as given; the mark stays in the result and the log.
|
||||
const rawKeys = opts.dangerouslyInteract === true || opts.submitOnly === true;
|
||||
// act as keystrokes (a menu shortcut, say). A terminal seat's exact text would run the label as a command. Their bytes
|
||||
// stay exactly as given; the mark stays in the result and the log.
|
||||
const rawKeys = opts.dangerouslyInteract === true || opts.submitOnly === true || opts.typedExactly === true;
|
||||
const result = await this.sendClassified(sessionName, rawKeys ? text : withUrgentLine(text, opts.urgent.by), opts);
|
||||
return { ...result, urgent: opts.urgent };
|
||||
}
|
||||
@@ -3781,6 +3801,7 @@ export class SessionTransport {
|
||||
// (h) thread the resolved stampISO so send()'s delivered-latency calc measures from the SAME
|
||||
// compose stamp the envelope carries (opts may not have carried one; the local stampISO is truth).
|
||||
const result = await this.send(session.sessionName, perRecipientText, { ...opts, stampISO,
|
||||
exactText: opts?.envelopeSender ? text : undefined,
|
||||
deliveryId: opts?.deliveryId ? `${opts.deliveryId}:${session.sessionName}` : undefined });
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { resolveGuardTarget } from "./seat-delivery-guard.js";
|
||||
|
||||
/** Exact-text selection needs the same unique current target as input delivery,
|
||||
* plus a binding to the actual tmux address. Historical rows and unbound
|
||||
* canonical names alone do not establish what is receiving this input. */
|
||||
export function isBoundTerminalSeat(db: Database.Database, sessionName: string): boolean {
|
||||
try {
|
||||
const target = resolveGuardTarget(db, sessionName);
|
||||
if (!target) return false;
|
||||
const current = db.prepare(
|
||||
`SELECT n.runtime AS runtime FROM nodes n JOIN bindings b ON b.node_id = n.id
|
||||
WHERE n.id = ? AND (b.tmux_session = ? OR b.tmux_pane = ?)`,
|
||||
).get(target.nodeId, sessionName, sessionName) as { runtime: string | null } | undefined;
|
||||
return current?.runtime === "terminal";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** A `runtime: terminal` node, by its newest session row or else its composed canonical name
|
||||
* (`<logical id with dashes>@<rig>`). A database that can't answer reads as not terminal. */
|
||||
export function isTerminalSeat(db: Database.Database, sessionName: string): boolean {
|
||||
try {
|
||||
const exact = db.prepare(
|
||||
`SELECT n.runtime AS runtime FROM sessions s JOIN nodes n ON n.id = s.node_id
|
||||
WHERE s.session_name = ? ORDER BY s.id DESC LIMIT 1`,
|
||||
).get(sessionName) as { runtime: string | null } | undefined;
|
||||
if (exact) return exact.runtime === "terminal";
|
||||
const at = sessionName.lastIndexOf("@");
|
||||
if (at <= 0) return false;
|
||||
const composed = db.prepare(
|
||||
`SELECT n.runtime AS runtime FROM nodes n JOIN rigs r ON r.id = n.rig_id
|
||||
WHERE r.name = ? AND REPLACE(n.logical_id, '.', '-') = ? LIMIT 1`,
|
||||
).get(sessionName.slice(at + 1), sessionName.slice(0, at)) as { runtime: string | null } | undefined;
|
||||
return composed?.runtime === "terminal";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
submitOnly?: boolean;
|
||||
expectedStagedText?: string;
|
||||
expectedStagedLineCount?: number;
|
||||
/** The unwrapped text; typed instead of `text` when the recipient is a terminal seat. */
|
||||
exactText?: string;
|
||||
urgent?: boolean;
|
||||
}>();
|
||||
|
||||
@@ -101,6 +103,7 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
return c.json({ ok: false, error: resolved.error }, status);
|
||||
}
|
||||
|
||||
const exactText = typeof body.exactText === "string" ? body.exactText : undefined;
|
||||
const result = await transport.send(body.session, body.text ?? "", {
|
||||
deliveryId: body.deliveryId,
|
||||
verify: body.verify,
|
||||
@@ -118,6 +121,7 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
submitOnly: body.submitOnly,
|
||||
expectedStagedText: body.expectedStagedText,
|
||||
expectedStagedLineCount: body.expectedStagedLineCount,
|
||||
exactText,
|
||||
// The caller's request: if it goes away while the send still waits, nothing is typed later.
|
||||
signal: c.req.raw.signal,
|
||||
});
|
||||
@@ -183,7 +187,7 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
const entry = outbox.record({
|
||||
senderSession: derivedActor,
|
||||
destinationSession: body.session,
|
||||
body: body.text,
|
||||
body: result.envelopeOmitted && exactText !== undefined ? exactText : body.text,
|
||||
identityProvenance: "transport:v1",
|
||||
});
|
||||
// The input was dispatched successfully; mark its audit row as broadcast does.
|
||||
|
||||
@@ -48,6 +48,9 @@ function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean }
|
||||
const observations: unknown[] = [];
|
||||
const hooks: { load?: () => void | Promise<void>; paste?: () => void | Promise<void>; sleep?: (ms: number) => void | Promise<void> } = {};
|
||||
let staged = "";
|
||||
// A terminal pane shows a submitted line as typed; an agent pane echoes a sent message under its prompt, as Claude does.
|
||||
const echo = (node: string, body: string) => (db.prepare("SELECT runtime FROM nodes WHERE id=?").get(node) as { runtime: string }).runtime === "terminal"
|
||||
? `${body}\n` : `${body.split("\n").map((row, i) => (i ? " " : "❯ ") + row).join("\n")}\n`;
|
||||
const tmux = new TmuxAdapter(async command => {
|
||||
if (command.includes("load-buffer")) await hooks.load?.();
|
||||
const node = command.includes("%2") ? "b" : "a";
|
||||
@@ -58,7 +61,7 @@ function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean }
|
||||
if (command.includes("send-keys")) {
|
||||
writes.push(`keys:${node}:${command}`);
|
||||
if (command.includes("Enter")) {
|
||||
submissions.push(bodies[node] ?? ""); history[node] += `${bodies[node]}\n`; bodies[node] = "";
|
||||
submissions.push(bodies[node] ?? ""); history[node] += echo(node, bodies[node] ?? ""); bodies[node] = "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
@@ -107,7 +110,7 @@ describe("current draft-aware delivery through the real transport", () => {
|
||||
});
|
||||
|
||||
it("submits its delayed collapsed paste once, leaving the next queue wake deliverable", async () => {
|
||||
const f = fixture(); await f.enable();
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
|
||||
const text = "first line\nsecond line\nlast line";
|
||||
let readsAfterPaste = 0;
|
||||
vi.mocked(f.tmux.captureComposerSnapshot).mockImplementation(async name => {
|
||||
@@ -342,7 +345,7 @@ describe("current draft-aware delivery through the real transport", () => {
|
||||
|
||||
describe("supported seat and queue integration", () => {
|
||||
it.each(["failed", "indeterminate", "sending"])("startup recovery attempts a still-current %s queue wake", async state => {
|
||||
const f = fixture(); await f.enable();
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
|
||||
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
|
||||
const id = "wake-intent-" + item.qitemId;
|
||||
@@ -364,7 +367,7 @@ describe("supported seat and queue integration", () => {
|
||||
});
|
||||
|
||||
it.each(["delivered", "retained", "retired"])("startup recovery leaves a %s queue wake alone", async state => {
|
||||
const f = fixture(); await f.enable();
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
|
||||
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
|
||||
const id = "wake-intent-" + item.qitemId;
|
||||
@@ -378,7 +381,7 @@ describe("supported seat and queue integration", () => {
|
||||
|
||||
it.each(["closed", "claimed", "reassigned", "absent-pane", "replacement"].flatMap(state =>
|
||||
[false, true].map(draftAware => ({ state, draftAware }))))("recovery does not paste for $state work (draft-aware: $draftAware)", async ({ state, draftAware }) => {
|
||||
const f = fixture(); if (draftAware) await f.enable();
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); if (draftAware) await f.enable();
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
|
||||
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
|
||||
const id = "wake-intent-" + item.qitemId;
|
||||
@@ -396,7 +399,7 @@ describe("supported seat and queue integration", () => {
|
||||
});
|
||||
|
||||
it("tries a failed recovery once per pass even when the native result stays unknown", async () => {
|
||||
const f = fixture(); await f.enable();
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
|
||||
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
|
||||
const id = "wake-intent-" + item.qitemId;
|
||||
@@ -410,7 +413,7 @@ describe("supported seat and queue integration", () => {
|
||||
});
|
||||
|
||||
it("keeps earlier uncertainty when a recovery fails before input", async () => {
|
||||
const f = fixture();
|
||||
const f = fixture(undefined, { runtime: "claude-code" });
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "work", nudge: false });
|
||||
const id = "wake-intent-" + item.qitemId;
|
||||
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
|
||||
@@ -423,7 +426,7 @@ describe("supported seat and queue integration", () => {
|
||||
});
|
||||
|
||||
it("does not describe an earlier attempt for a never-attempted pending wake", async () => {
|
||||
const f = fixture();
|
||||
const f = fixture(undefined, { runtime: "claude-code" });
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "work", nudge: false });
|
||||
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
|
||||
await f.repo.drainPendingWakeIntents();
|
||||
@@ -432,7 +435,7 @@ describe("supported seat and queue integration", () => {
|
||||
});
|
||||
|
||||
it("reads current recovery candidates without visiting historical uncertain notifications", async () => {
|
||||
const f = fixture();
|
||||
const f = fixture(undefined, { runtime: "claude-code" });
|
||||
const old = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "old work", nudge: false });
|
||||
f.db.prepare("UPDATE queue_items SET state='done' WHERE qitem_id=?").run(old.qitemId);
|
||||
f.db.transaction(() => { for (let i = 0; i < 600; i++) f.outbox.record({ outboxId: `wake-intent-history-${i}`, senderSession: "sender@test", destinationSession: "worker@test", body: "historical", auditPointer: old.qitemId }); })();
|
||||
@@ -448,7 +451,7 @@ describe("supported seat and queue integration", () => {
|
||||
|
||||
it.each(["draft_input_changed", "delivery_indeterminate", "submit_failed", "send_failed"])(
|
||||
"keeps a committed wake's %s outcome indeterminate in the queue", async reason => {
|
||||
const f = fixture(); await f.enable();
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
|
||||
const id = "wake-intent-" + item.qitemId;
|
||||
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
|
||||
@@ -468,7 +471,7 @@ describe("supported seat and queue integration", () => {
|
||||
);
|
||||
|
||||
it.each(["ladder", "legacy-boot"])("delivers a %s nudge without a precommitted outbox entry", async path => {
|
||||
const f = fixture(); await f.enable();
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
|
||||
if (path === "ladder") await f.repo.maybeNudge(item.qitemId, "worker@test", true, "sender@test");
|
||||
else await f.repo.recoverLostNudge(item.qitemId, "worker@test", "sender@test");
|
||||
@@ -479,7 +482,7 @@ describe("supported seat and queue integration", () => {
|
||||
});
|
||||
|
||||
it.each(["current", "closed", "reassigned"])("checks the %s queue facts without saved delivery bookkeeping", async state => {
|
||||
const f = fixture(); await f.enable();
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
|
||||
f.db.exec("CREATE TRIGGER unavailable_nudge_receipt BEFORE UPDATE OF guard_delivery ON outbox_entries BEGIN SELECT RAISE(ABORT, 'fixture receipt unavailable'); END;");
|
||||
f.hooks.load = () => {
|
||||
@@ -549,8 +552,9 @@ describe("supported seat and queue integration", () => {
|
||||
expect(f.submissions).toEqual(["sibling message", "incoming message"]);
|
||||
});
|
||||
|
||||
// Queue wakes go to an agent seat: a wake to a terminal seat types nothing before any draft check (gateway-destination-routing covers it).
|
||||
it("a held queue wake remains on the existing queue recovery path without a second scheduler", async () => {
|
||||
const f = fixture(); await f.enable(); f.bodies.a = "draft";
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable(); f.bodies.a = "draft";
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work" });
|
||||
await vi.waitFor(() => expect(f.repo.getById(item.qitemId)!.lastNudgeResult).toBe("retained:draft_aware"));
|
||||
const id = "wake-intent-" + item.qitemId;
|
||||
@@ -561,7 +565,7 @@ describe("supported seat and queue integration", () => {
|
||||
});
|
||||
|
||||
it("rechecks queue applicability before paste", async () => {
|
||||
const f = fixture(); await f.enable();
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable();
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "queued work", nudge: false });
|
||||
const id = "wake-intent-" + item.qitemId; f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
|
||||
f.hooks.load = () => { f.db.prepare("UPDATE queue_items SET state='done' WHERE qitem_id=?").run(item.qitemId); };
|
||||
@@ -571,7 +575,7 @@ describe("supported seat and queue integration", () => {
|
||||
});
|
||||
|
||||
it.each([false, true])("coalesced wakes preserve every original ID and body (held: %s)", async held => {
|
||||
const f = fixture(); await f.enable(); if (held) f.bodies.a = "draft"; const ids: string[] = [];
|
||||
const f = fixture(undefined, { runtime: "claude-code" }); await f.enable(); if (held) f.bodies.a = "draft"; const ids: string[] = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const item = await f.repo.create({ sourceSession: "sender@test", destinationSession: "worker@test", body: "work " + i, nudge: false });
|
||||
f.repo.stageWakeIntent(item.qitemId, "sender@test", "worker@test", null, true);
|
||||
|
||||
@@ -28,6 +28,8 @@ import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js"
|
||||
import { queueTransitionsArchiveSchema } from "../src/db/migrations/054_queue_transitions_archive.js";
|
||||
import { ownerNotificationLevelsSchema } from "../src/db/migrations/076_owner_notification_levels.js";
|
||||
import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_cli_attachment.js";
|
||||
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
|
||||
import { occupantTenuresSchema } from "../src/db/migrations/060_occupant_tenures.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
import { QueueRepository } from "../src/domain/queue-repository.js";
|
||||
@@ -58,7 +60,7 @@ function makeHarness(opts?: { registryReadable?: () => boolean; entities?: () =>
|
||||
migrate(db, [
|
||||
coreSchema, bindingsSessionsSchema, externalCliAttachmentSchema, eventsSchema, queueItemsSchema,
|
||||
queueTransitionsSchema, outboxEntriesSchema, queueTransitionsArchiveSchema,
|
||||
ownerNotificationLevelsSchema,
|
||||
ownerNotificationLevelsSchema, rigArchiveSchema, occupantTenuresSchema,
|
||||
]);
|
||||
const bus = new EventBus(db);
|
||||
const sends: Array<{ session: string; text: string }> = [];
|
||||
@@ -184,8 +186,9 @@ describe("OPR.0.5.6.14 — one destination resolver, no fall-through", () => {
|
||||
body: "pane-backed founder seat",
|
||||
});
|
||||
await vi.waitFor(() => expect(h.repo.getById(item.qitemId)!.lastNudgeResult).not.toBeNull());
|
||||
expect(h.sends.map((send) => send.session)).toEqual(["human-founder@rig1"]);
|
||||
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toMatch(/^failed:/);
|
||||
// Pane-bound, not gateway-owned. The pane is a terminal seat, so the wake is not typed into it.
|
||||
expect(h.sends).toHaveLength(0);
|
||||
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toMatch(/^failed:terminal-seat: /);
|
||||
});
|
||||
|
||||
it("PANELLESS TOPOLOGY IS NOT A TERMINAL: an external_cli-bound registered human stays gateway-routable", async () => {
|
||||
@@ -618,3 +621,77 @@ describe("GET /api/queue/:qitemId carries the owner notification level", () => {
|
||||
h.db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// A terminal seat runs a shell or a TUI, not an agent: a typed wake would run as a command line.
|
||||
describe("a terminal seat never gets a typed queue wake", () => {
|
||||
let h: ReturnType<typeof makeHarness>;
|
||||
beforeEach(() => { h = makeHarness(); });
|
||||
|
||||
function seedTerminalSeat(rig: string, logicalId: string, session: string) {
|
||||
h.db.prepare("INSERT OR IGNORE INTO rigs (id, name) VALUES (?, ?)").run(`rig-${rig}`, rig);
|
||||
const { id: rigId } = h.db.prepare("SELECT id FROM rigs WHERE name = ?").get(rig) as { id: string };
|
||||
h.db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime) VALUES (?, ?, ?, 'terminal')").run(`node-${logicalId}`, rigId, logicalId);
|
||||
h.db.prepare("INSERT INTO sessions (id, node_id, session_name) VALUES (?, ?, ?)").run(`sess-${logicalId}`, `node-${logicalId}`, session);
|
||||
h.db.prepare("INSERT INTO bindings (id, node_id, attachment_type, tmux_session) VALUES (?, ?, 'tmux', ?)").run(`binding-${logicalId}`, `node-${logicalId}`, session);
|
||||
}
|
||||
|
||||
it.each([
|
||||
["a shell seat", "rig1", "infra.shell", "infra-shell@rig1"],
|
||||
["the kernel's operator.human (rig tui)", "kernel", "operator.human", "operator-human@kernel"],
|
||||
])("%s: nothing is typed, the result is an honest failed:, the row stays queued, and a retry types nothing", async (_label, rig, logicalId, session) => {
|
||||
seedTerminalSeat(rig, logicalId, session);
|
||||
const item = await h.repo.create({
|
||||
sourceSession: "dev-a@rig1", destinationSession: session,
|
||||
summary: "x; touch /tmp/should-not-run", evidenceRef: EVIDENCE, body: "preserve these bytes",
|
||||
});
|
||||
await vi.waitFor(() => expect(h.repo.getById(item.qitemId)!.lastNudgeResult).not.toBeNull());
|
||||
expect(h.sends).toHaveLength(0);
|
||||
expect(h.repo.getById(item.qitemId)).toMatchObject({
|
||||
state: "pending", body: "preserve these bytes", lastNudgeResult: expect.stringMatching(/^failed:terminal-seat: /),
|
||||
});
|
||||
// The wake ladder retries through maybeNudge.
|
||||
await h.repo.maybeNudge(item.qitemId, session, true, "dev-a@rig1");
|
||||
expect(h.sends).toHaveLength(0);
|
||||
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toMatch(/^failed:terminal-seat: /);
|
||||
});
|
||||
|
||||
it("a terminal seat known only by its composed name (binding, no session row) gets no typed wake", async () => {
|
||||
h.db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime) VALUES ('node-logs', 'rig-1', 'infra.logs', 'terminal')").run();
|
||||
h.db.prepare("INSERT INTO bindings (id, node_id, attachment_type, tmux_session) VALUES ('binding-logs', 'node-logs', 'tmux', 'infra-logs@rig1')").run();
|
||||
const item = await h.repo.create({
|
||||
sourceSession: "dev-a@rig1", destinationSession: "infra-logs@rig1",
|
||||
summary: "x; touch /tmp/should-not-run", evidenceRef: EVIDENCE, body: "composed seat",
|
||||
});
|
||||
await vi.waitFor(() => expect(h.repo.getById(item.qitemId)!.lastNudgeResult).not.toBeNull());
|
||||
expect(h.sends).toHaveLength(0);
|
||||
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toMatch(/^failed:terminal-seat: /);
|
||||
});
|
||||
|
||||
it("an agent seat still gets its typed wake", async () => {
|
||||
seedTerminalSeat("rig1", "infra.shell", "infra-shell@rig1");
|
||||
const item = await h.repo.create({
|
||||
sourceSession: "infra-shell@rig1", destinationSession: "dev-a@rig1",
|
||||
summary: "agent work", evidenceRef: EVIDENCE, body: "for the agent",
|
||||
});
|
||||
await vi.waitFor(() => expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toBe("verified"));
|
||||
expect(h.sends).toHaveLength(1);
|
||||
expect(h.sends[0]!.session).toBe("dev-a@rig1");
|
||||
expect(h.sends[0]!.text).toContain("From: infra-shell@rig1");
|
||||
expect(h.sends[0]!.text).toContain(item.qitemId);
|
||||
});
|
||||
|
||||
it("a currently bound agent with older terminal history gets its wake and retry", async () => {
|
||||
seedTerminalSeat("rig1", "old.shell", "dev-a@rig1");
|
||||
// Retain the old terminal session row, but its binding has moved elsewhere.
|
||||
h.db.prepare("UPDATE bindings SET tmux_session='old-shell@rig1' WHERE node_id='node-old.shell'").run();
|
||||
const item = await h.repo.create({ sourceSession: "dev-b@rig1", destinationSession: "dev-a@rig1",
|
||||
summary: "current agent work", evidenceRef: EVIDENCE, body: "for the current occupant" });
|
||||
await vi.waitFor(() => expect(h.repo.getById(item.qitemId)!.lastNudgeResult).not.toBeNull());
|
||||
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toBe("verified");
|
||||
expect(h.sends).toHaveLength(1);
|
||||
await h.repo.maybeNudge(item.qitemId, "dev-a@rig1", true, "dev-b@rig1");
|
||||
expect(h.repo.getById(item.qitemId)!.lastNudgeResult).toBe("verified");
|
||||
expect(h.sends).toHaveLength(2);
|
||||
expect(h.sends.every(send => send.session === "dev-a@rig1")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,7 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
// The watchdog's deliver() makes exactly this call (startup.ts parked-owner delivery).
|
||||
const watchdogSend = (transport: SessionTransport, name: string) =>
|
||||
transport.send(name, "[OpenRig watchdog scheduler · policy: parked-owner-consumer] You are parked", {
|
||||
deliveryId: "guard-watchdog-job-1", actorSession: "watchdog@system", auditPointer: "job-1",
|
||||
deliveryId: "guard-watchdog-job-1", actorSession: "watchdog@system", auditPointer: "job-1", openrigNotice: true,
|
||||
});
|
||||
|
||||
it.each([["claude-code", "zsh"], ["codex", "-bash"]])("%s seat showing %s: refused, nothing typed", async (runtime, shell) => {
|
||||
@@ -315,13 +315,18 @@ describe("#142 transport refuses to type into a bare shell where an agent runtim
|
||||
}
|
||||
});
|
||||
|
||||
it("negative: a terminal node's shell is its runtime, so it still receives text", async () => {
|
||||
it("negative: a terminal node's shell is its runtime, so a person's send and OpenRig's own notice both still type", async () => {
|
||||
seat("terminal", "ops-human@my-rig");
|
||||
const { tmux, sendText } = tmuxWithPane(async () => "zsh");
|
||||
const result = await watchdogSend(new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux }), "ops-human@my-rig");
|
||||
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const personal = await transport.send("ops-human@my-rig", "printf 'hello\\n'", { actorSession: "dev-impl@my-rig" });
|
||||
expect(personal.ok).toBe(true);
|
||||
expect(sendText).toHaveBeenCalledOnce();
|
||||
|
||||
const watchdog = await watchdogSend(transport, "ops-human@my-rig");
|
||||
expect(watchdog.ok).toBe(true);
|
||||
expect(sendText).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each([["unknown", async () => null], ["unreadable", async () => { throw new Error("tmux failed"); }]])(
|
||||
|
||||
@@ -2676,6 +2676,107 @@ describe("SessionTransport", () => {
|
||||
expect(sendTextSpy).not.toHaveBeenCalled();
|
||||
} finally { clock.restore(); }
|
||||
});
|
||||
|
||||
// A terminal seat's shell runs every typed line, so it gets the exact text, never the envelope.
|
||||
function seedTerminalSeat() {
|
||||
const rig = rigRepo.createRig("term-rig");
|
||||
const node = rigRepo.addNode(rig.id, "infra.ui", { role: "ui", runtime: "terminal" });
|
||||
const session = sessionRegistry.registerSession(node.id, "infra-ui@term-rig");
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: "infra-ui@term-rig" });
|
||||
}
|
||||
const HEREDOC = "cat <<'EOF'\nSent: as typed\nEOF";
|
||||
|
||||
it("send to a terminal seat types exactText exactly, with no envelope and no delivered segment", async () => {
|
||||
seedTerminalSeat();
|
||||
const sent: string[] = [];
|
||||
const tmux = mockTmux({ getPaneCommand: async () => "zsh", sendText: async (_t, text) => { sent.push(text); return { ok: true }; } });
|
||||
// The write moment is 30s after the compose stamp: an enveloped send would gain "delivered +30s".
|
||||
const transport = createTransport(tmux, { now: () => new Date("2026-08-06T17:42:39Z") });
|
||||
|
||||
const result = await transport.send("infra-ui@term-rig", H_ENVELOPE, { exactText: HEREDOC, stampISO: "2026-08-06T17:42:09Z" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.envelopeOmitted).toBe(true);
|
||||
expect(sent).toEqual([HEREDOC]);
|
||||
});
|
||||
|
||||
it("send to an agent seat ignores exactText and keeps the envelope", async () => {
|
||||
seedCanonicalRig();
|
||||
const sent: string[] = [];
|
||||
const transport = createTransport(mockTmux({ sendText: async (_t, text) => { sent.push(text); return { ok: true }; } }));
|
||||
|
||||
const result = await transport.send("dev-impl@my-rig", H_ENVELOPE, { exactText: "hi" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.envelopeOmitted).toBeUndefined();
|
||||
expect(sent).toEqual([H_ENVELOPE]);
|
||||
});
|
||||
|
||||
it("enveloped fan-out to a terminal seat and an agent seat gives each the right text", async () => {
|
||||
seedCanonicalRig(); // dev-impl@my-rig, claude-code
|
||||
seedTerminalSeat(); // infra-ui@term-rig, terminal
|
||||
const sent = new Map<string, string>();
|
||||
const tmux = mockTmux({ getPaneCommand: async () => "zsh", sendText: async (target, text) => { sent.set(target, text); return { ok: true }; } });
|
||||
const transport = createTransport(tmux);
|
||||
|
||||
const result = await transport.broadcast(
|
||||
{ sessions: ["dev-impl@my-rig", "infra-ui@term-rig"] },
|
||||
"make test",
|
||||
{ envelopeSender: "orch@my-rig", stampISO: "2026-08-06T17:42:09Z" },
|
||||
);
|
||||
|
||||
expect(result.sent).toBe(2);
|
||||
expect(sent.get("infra-ui@term-rig")).toBe("make test");
|
||||
expect(sent.get("dev-impl@my-rig")).toContain("From: orch@my-rig");
|
||||
expect(sent.get("dev-impl@my-rig")).toContain("make test");
|
||||
const byName = new Map(result.results.map((r) => [r.sessionName, r]));
|
||||
expect(byName.get("infra-ui@term-rig")?.envelopeOmitted).toBe(true);
|
||||
expect(byName.get("dev-impl@my-rig")?.envelopeOmitted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a person's send (a seat actor) to a terminal seat still gets the exact text", async () => {
|
||||
seedTerminalSeat();
|
||||
const sent: string[] = [];
|
||||
const transport = createTransport(mockTmux({ getPaneCommand: async () => "zsh", sendText: async (_t, text) => { sent.push(text); return { ok: true }; } }));
|
||||
const result = await transport.send("infra-ui@term-rig", H_ENVELOPE, { exactText: "make test", actorSession: "dev-impl@my-rig" });
|
||||
expect(result).toMatchObject({ ok: true, envelopeOmitted: true });
|
||||
expect(sent).toEqual(["make test"]);
|
||||
});
|
||||
|
||||
it("an urgent send to a terminal seat types the exact text without the urgent line, and keeps the mark", async () => {
|
||||
seedTerminalSeat();
|
||||
const sent: string[] = [];
|
||||
const transport = createTransport(mockTmux({ getPaneCommand: async () => "zsh", sendText: async (_t, text) => { sent.push(text); return { ok: true }; } }));
|
||||
const result = await transport.send("infra-ui@term-rig", H_ENVELOPE, { exactText: "make test", actorSession: "dev-impl@my-rig", urgent: { by: "dev-impl@my-rig" } });
|
||||
expect(result).toMatchObject({ ok: true, envelopeOmitted: true, urgent: { by: "dev-impl@my-rig" } });
|
||||
expect(sent).toEqual(["make test"]);
|
||||
});
|
||||
|
||||
it("a terminal seat known only by its composed name (binding, no session row): exact text for a person; a watchdog notice carries only its marker (with no delivery guard here, it types as given)", async () => {
|
||||
const rig = rigRepo.createRig("term-rig");
|
||||
const node = rigRepo.addNode(rig.id, "infra.logs", { role: "logs", runtime: "terminal" });
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: "infra-logs@term-rig" });
|
||||
const sent: string[] = [];
|
||||
const transport = createTransport(mockTmux({ getPaneCommand: async () => "zsh", sendText: async (_t, text) => { sent.push(text); return { ok: true }; } }));
|
||||
|
||||
const personal = await transport.send("infra-logs@term-rig", H_ENVELOPE, { exactText: "tail -f app.log", actorSession: "dev-impl@my-rig" });
|
||||
expect(personal).toMatchObject({ ok: true, envelopeOmitted: true });
|
||||
expect(sent).toEqual(["tail -f app.log"]);
|
||||
|
||||
const notice = await transport.send("infra-logs@term-rig", "[OpenRig watchdog] a reminder", { actorSession: "watchdog@system", openrigNotice: true });
|
||||
expect(notice).toMatchObject({ ok: true });
|
||||
expect(sent).toEqual(["tail -f app.log", "[OpenRig watchdog] a reminder"]);
|
||||
});
|
||||
|
||||
it("a person's send from a seat in a rig named system still gets the exact text", async () => {
|
||||
seedTerminalSeat();
|
||||
const sent: string[] = [];
|
||||
const transport = createTransport(mockTmux({ getPaneCommand: async () => "zsh", sendText: async (_t, text) => { sent.push(text); return { ok: true }; } }));
|
||||
const result = await transport.send("infra-ui@term-rig", H_ENVELOPE, { exactText: "make test", actorSession: "ops-member@system" });
|
||||
expect(result).toMatchObject({ ok: true, envelopeOmitted: true });
|
||||
expect(sent).toEqual(["make test"]);
|
||||
});
|
||||
});
|
||||
|
||||
// #1084/#1103 edge cases, contributed by z4cc in review (taken verbatim,
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { Hono } from "hono";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { sendCommand, wrapSendBody, type SendDeps } from "../../cli/src/commands/send.js";
|
||||
import { senderIdentityHeaders } from "../../cli/src/client.js";
|
||||
import { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SessionTransport } from "../src/domain/session-transport.js";
|
||||
import { isBoundTerminalSeat, isTerminalSeat } from "../src/domain/terminal-seat.js";
|
||||
import { transportRoutes } from "../src/routes/transport.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
const BODY = "printf '%s\\n' 'terminal-body-marker' 'From: user-authored' '---' '↩ Reply: user-authored'";
|
||||
const OUTPUT = "terminal-body-marker\nFrom: user-authored\n---\n↩ Reply: user-authored\n";
|
||||
|
||||
// Commander -> actual routes/SQLite/transport/file-backed adapter. The argv seam
|
||||
// models a paste and Enter; only an owned /bin/sh executes that exact stream.
|
||||
// This is not a live pane, SSH connection, agent or terminal-emulator claim.
|
||||
describe("terminal-runtime send framing", () => {
|
||||
let db: ReturnType<typeof createFullTestDb>;
|
||||
let scratch: string;
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
scratch = mkdtempSync(join(tmpdir(), "terminal-framing-"));
|
||||
vi.stubEnv("OPENRIG_URL", "http://local-fixture:7433");
|
||||
vi.stubEnv("OPENRIG_SESSION_NAME", "operator@framing");
|
||||
vi.stubEnv("OPENRIG_HOST_SELECTED", "");
|
||||
});
|
||||
afterEach(() => { db.close(); rmSync(scratch, { recursive: true, force: true }); vi.unstubAllEnvs(); });
|
||||
|
||||
function fixture(runtime: "terminal" | "claude-code" = "terminal", execute = true) {
|
||||
const rigRepo = new RigRepository(db);
|
||||
const registry = new SessionRegistry(db);
|
||||
const rig = rigRepo.createRig("framing");
|
||||
const name = "shell@framing";
|
||||
const node = rigRepo.addNode(rig.id, "shell", { runtime });
|
||||
const session = registry.registerSession(node.id, name);
|
||||
registry.updateStatus(session.id, "running");
|
||||
registry.updateBinding(node.id, { tmuxSession: name });
|
||||
const pasted: string[] = [];
|
||||
const commands: string[][] = [];
|
||||
const executions: Array<{ status: number | null; stdout: string; stderr: string }> = [];
|
||||
let buffer = "";
|
||||
let input = "";
|
||||
const tmux = new TmuxAdapter(async () => { throw new Error("argv executor expected"); }, {
|
||||
tmpName: () => join(scratch, "input"), bufferName: () => "terminal-framing",
|
||||
writeFile: async (_file, text) => { buffer = text; }, unlink: async () => {},
|
||||
}, async argv => {
|
||||
commands.push(argv);
|
||||
if (argv[1] === "paste-buffer") { pasted.push(buffer); input += buffer; }
|
||||
if (argv[1] === "send-keys" && argv.includes("Enter") && runtime === "terminal" && execute) {
|
||||
const result = spawnSync("/bin/sh", ["-s"], { input: input + "\n", cwd: scratch,
|
||||
env: { PATH: "/usr/bin:/bin", HOME: scratch }, encoding: "utf8", timeout: 3_000 });
|
||||
if (result.error) throw result.error;
|
||||
executions.push({ status: result.status, stdout: result.stdout, stderr: result.stderr });
|
||||
input = "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
tmux.getPanePid = async () => null;
|
||||
tmux.getPaneCommand = async () => null;
|
||||
tmux.capturePaneContent = async () => "idle\n❯ ";
|
||||
const transport = new SessionTransport({ db, rigRepo, sessionRegistry: registry, tmuxAdapter: tmux, sleep: async () => {} });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("sessionTransport" as never, transport); await next(); });
|
||||
app.route("/api/transport", transportRoutes());
|
||||
const requests: Array<{ path: string; body: Record<string, unknown> }> = [];
|
||||
const sshArgv: string[][] = [];
|
||||
const deps = {
|
||||
lifecycleDeps: { exists: () => false, readFile: () => null, isProcessAlive: () => false,
|
||||
fetch: async () => ({ ok: true, json: async () => ({ selfHostId: "fixture-host" }) }),
|
||||
spawn: () => { throw new Error("No lifecycle allowed"); }, kill: () => { throw new Error("No lifecycle allowed"); } },
|
||||
clientFactory: () => {
|
||||
const client = { originSelfHostId: undefined as string | undefined,
|
||||
post: async (path: string, body: Record<string, unknown>) => {
|
||||
requests.push({ path, body });
|
||||
const res = await app.request(path, { method: "POST", headers: {
|
||||
"Content-Type": "application/json", ...senderIdentityHeaders(client.originSelfHostId),
|
||||
}, body: JSON.stringify(body) });
|
||||
return { status: res.status, data: await res.json() };
|
||||
} };
|
||||
return client;
|
||||
},
|
||||
hostRegistryLoader: () => ({ ok: true, registry: { hosts: [
|
||||
{ id: "remote-http", transport: "http", url: "http://remote-fixture:7433" },
|
||||
{ id: "remote-ssh", transport: "ssh", target: "fixture.invalid" },
|
||||
] } }),
|
||||
crossHostRun: async (_host: unknown, argv: readonly string[]) => {
|
||||
sshArgv.push([...argv]);
|
||||
await new Command().addCommand(sendCommand(deps as unknown as SendDeps)).parseAsync(["node", ...argv]);
|
||||
return { ok: true, exitCode: 0, stdout: "", stderr: "" };
|
||||
},
|
||||
};
|
||||
return { name, pasted, commands, executions, requests, sshArgv, node, session, registry,
|
||||
rigRepo, rig, transport, deps: deps as unknown as SendDeps };
|
||||
}
|
||||
|
||||
async function run(f: ReturnType<typeof fixture>, args: string[]) {
|
||||
const previous = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
await new Command().addCommand(sendCommand(f.deps)).parseAsync(["node", "rig", "send", ...args, "--json"]);
|
||||
return process.exitCode ?? 0;
|
||||
} finally { log.mockRestore(); error.mockRestore(); process.exitCode = previous; }
|
||||
}
|
||||
|
||||
it.each(["local", "fan-out", "http", "ssh"])("%s delivers only the intended terminal body", async route => {
|
||||
const f = fixture();
|
||||
const args = route === "fan-out" ? ["--to", f.name, BODY]
|
||||
: [f.name, BODY, ...(route === "http" ? ["--host", "remote-http"] : route === "ssh" ? ["--host", "remote-ssh"] : [])];
|
||||
expect(await run(f, args)).toBe(0);
|
||||
expect(f.executions).toHaveLength(1);
|
||||
expect(f.executions[0]!.stdout).toBe(OUTPUT); // Body runs even on main; envelope also runs there.
|
||||
expect(f.executions[0]!.stderr).toBe("");
|
||||
expect(f.executions[0]!.status).toBe(0);
|
||||
expect(f.pasted).toEqual([BODY]); // No guessing at user-authored From:/---/reply bytes.
|
||||
if (route === "ssh") expect(f.sshArgv[0]!.slice(0, 4)).toEqual(["rig", "send", f.name, BODY]);
|
||||
});
|
||||
|
||||
it("keeps deliberate raw terminal input byte-exact", async () => {
|
||||
const f = fixture();
|
||||
expect(await run(f, [f.name, BODY, "--raw"])).toBe(0);
|
||||
expect(f.pasted).toEqual([BODY]);
|
||||
expect(f.executions).toEqual([{ status: 0, stdout: OUTPUT, stderr: "" }]);
|
||||
});
|
||||
|
||||
it("direct HTTP explicit interaction keeps text when exactText is also supplied", async () => {
|
||||
const f = fixture("terminal", false);
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("sessionTransport" as never, f.transport); await next(); });
|
||||
app.route("/api/transport", transportRoutes());
|
||||
const response = await app.request("/api/transport/send", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session: f.name, text: "explicit input", exactText: "different exact body",
|
||||
dangerouslyInteract: true, reason: "private explicit input control" }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).ok).toBe(true);
|
||||
expect(f.pasted).toEqual(["explicit input"]);
|
||||
expect(f.commands.filter(argv => argv[1] === "send-keys" && argv.includes("Enter"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps a non-terminal envelope with unreadable process observations", async () => {
|
||||
const f = fixture("claude-code");
|
||||
expect(await run(f, [f.name, BODY])).toBe(0);
|
||||
expect(f.pasted).toHaveLength(1);
|
||||
expect(f.pasted[0]).toContain("From: operator@framing\nTo: " + f.name);
|
||||
expect(f.pasted[0]).toContain("\n---\n" + BODY + "\n---\n↩ Reply:");
|
||||
expect(f.executions).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["moved binding", "unbound canonical", "ambiguous binding"])(
|
||||
"does not infer an exact-text target from %s", async shape => {
|
||||
const f = fixture("terminal", false);
|
||||
if (shape === "moved binding") f.registry.updateBinding(f.node.id, { tmuxSession: "other@framing" });
|
||||
if (shape === "unbound canonical") {
|
||||
db.prepare("DELETE FROM sessions WHERE id = ?").run(f.session.id);
|
||||
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(f.node.id);
|
||||
}
|
||||
if (shape === "ambiguous binding") {
|
||||
const other = f.rigRepo.addNode(f.rig.id, "other", { runtime: "claude-code" });
|
||||
f.registry.updateBinding(other.id, { tmuxSession: f.name });
|
||||
}
|
||||
// Historical classification alone does not establish the current binding.
|
||||
expect(isTerminalSeat(db, f.name)).toBe(true);
|
||||
expect(isBoundTerminalSeat(db, f.name)).toBe(false);
|
||||
const original = wrapSendBody("operator@framing", f.name, BODY);
|
||||
const result = await f.transport.send(f.name, original, { exactText: BODY });
|
||||
expect(result.ok).toBe(true); // No new refusal or raw-mode inference.
|
||||
expect(result.envelopeOmitted).toBeUndefined();
|
||||
expect(f.pasted).toEqual([original]);
|
||||
});
|
||||
|
||||
it("a real current binding establishes terminal input even without a session-history row", async () => {
|
||||
const f = fixture("terminal", false);
|
||||
db.prepare("DELETE FROM sessions WHERE id = ?").run(f.session.id);
|
||||
expect(isBoundTerminalSeat(db, f.name)).toBe(true);
|
||||
const result = await f.transport.send(f.name, "rendered envelope", { exactText: BODY });
|
||||
expect(result).toMatchObject({ ok: true, envelopeOmitted: true });
|
||||
expect(f.pasted).toEqual([BODY]);
|
||||
});
|
||||
|
||||
it("keeps an old client's unmarked envelope-looking text verbatim", async () => {
|
||||
const f = fixture("terminal", false);
|
||||
const original = wrapSendBody("operator@framing", f.name, BODY);
|
||||
const result = await f.transport.send(f.name, original);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.envelopeOmitted).toBeUndefined();
|
||||
expect(f.pasted).toEqual([original]);
|
||||
});
|
||||
|
||||
it("an OpenRig notice follows the current agent binding rather than old terminal history", async () => {
|
||||
const f = fixture("claude-code", false);
|
||||
const old = f.rigRepo.addNode(f.rig.id, "old.shell", { runtime: "terminal" });
|
||||
db.prepare("INSERT INTO sessions(id,node_id,session_name) VALUES ('zz-old-terminal',?,?)").run(old.id, f.name);
|
||||
f.registry.updateBinding(old.id, { tmuxSession: "old-shell@framing" });
|
||||
expect(isTerminalSeat(db, f.name)).toBe(true);
|
||||
expect(isBoundTerminalSeat(db, f.name)).toBe(false);
|
||||
const result = await f.transport.send(f.name, "current agent notice", { openrigNotice: true });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(f.pasted).toEqual(["current agent notice"]);
|
||||
});
|
||||
|
||||
it.each(["moved binding", "unbound canonical", "ambiguous binding"])(
|
||||
"an OpenRig notice takes the ordinary path for %s", async shape => {
|
||||
const f = fixture("terminal", false);
|
||||
if (shape === "moved binding") f.registry.updateBinding(f.node.id, { tmuxSession: "other@framing" });
|
||||
if (shape === "unbound canonical") db.prepare("DELETE FROM bindings WHERE node_id = ?").run(f.node.id);
|
||||
if (shape === "ambiguous binding") {
|
||||
const other = f.rigRepo.addNode(f.rig.id, "other", { runtime: "claude-code" });
|
||||
f.registry.updateBinding(other.id, { tmuxSession: f.name });
|
||||
}
|
||||
const result = await f.transport.send(f.name, "ordinary notice", { openrigNotice: true });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(f.pasted).toEqual(["ordinary notice"]);
|
||||
});
|
||||
|
||||
it("an OpenRig notice to a bound terminal seat types as before: the marker changes nothing by itself", async () => {
|
||||
const f = fixture("terminal", false);
|
||||
expect(isBoundTerminalSeat(db, f.name)).toBe(true);
|
||||
const result = await f.transport.send(f.name, "[OpenRig watchdog] a reminder", { openrigNotice: true });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.envelopeOmitted).toBeUndefined();
|
||||
expect(f.pasted).toEqual(["[OpenRig watchdog] a reminder"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user