mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 18:48:31 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f7a780d89 | ||
|
|
e301b095e0 | ||
|
|
f86357e8a4 | ||
|
|
f33839673f |
No files matched your search
@@ -143,6 +143,40 @@ function findPromptDraftBeforeFooter(paneContent: string): string | null {
|
||||
return truncateEvidence(priorTrimmed);
|
||||
}
|
||||
|
||||
// Ignore only complete echoes of text this transport actually pasted to this recipient.
|
||||
// Keep nonmatching lines (including adjacent choices) and the scan-window geometry. This
|
||||
// is classification-only: observers still receive the original capture, never these markers.
|
||||
function withoutSentEchoes(pane: string, sentTexts: readonly string[], pastedText?: string): string {
|
||||
const lines = pane.split("\n");
|
||||
const compact = (text: string) => text.replace(/\s+/g, "");
|
||||
const expected = sentTexts.map(compact).filter(Boolean);
|
||||
for (let start = 0; start < lines.length; start++) {
|
||||
const marker = /^\s*([❯›>])\s*(.*)$/.exec(lines[start]!);
|
||||
if (!marker) continue;
|
||||
let content = compact(marker[2]!);
|
||||
const placeholder = /^\[Pasted text #\d+ \+(\d+) lines\]$/.exec(marker[2]!);
|
||||
const ownPlaceholder = pastedText !== undefined && placeholder !== null &&
|
||||
Number(placeholder[1]) === pastedText.split("\n").length;
|
||||
for (let end = start; end < lines.length; end++) {
|
||||
if ((end === start && ownPlaceholder) || expected.includes(content)) {
|
||||
// Matching one option is not matching a whole menu. Leave that selector
|
||||
// intact when another option remains outside the echoed block.
|
||||
const next = lines.slice(end + 1).find((line) => line.trim());
|
||||
if (/^\d+\.\s/.test(marker[2]!) && /^\s*(?:[❯›]\s*)?\d+\.\s/.test(next ?? "")) break;
|
||||
lines[start] = marker[1] === ">" ? "[sent text]" : marker[1]!;
|
||||
for (let i = start + 1; i <= end; i++) {
|
||||
if (lines[i]!.trim()) lines[i] = "[sent text]";
|
||||
}
|
||||
start = end;
|
||||
break;
|
||||
}
|
||||
if (!expected.some((text) => text.startsWith(content))) break;
|
||||
if (end + 1 < lines.length) content += compact(lines[end + 1]!);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function classifyPaneActivity(paneContent: string): PaneActivityClassification {
|
||||
const lastNonBlank = trimPaneLines(paneContent);
|
||||
if (lastNonBlank.length === 0) {
|
||||
@@ -255,10 +289,12 @@ export async function probeSessionActivity(input: {
|
||||
/** S01/S02 P2: optional read-only observer of the capture this probe already takes. */
|
||||
captureObserver?: CaptureObserverSink;
|
||||
binding?: Omit<ObservedBinding, "sessionName">;
|
||||
sentTexts?: readonly string[];
|
||||
pastedText?: string;
|
||||
}): Promise<AgentActivity> {
|
||||
// Capture routing and observation labels must share the entry context. The
|
||||
// caller may reuse/mutate its input while hasSession is pending.
|
||||
const { sessionName, runtime, attachmentType, tmuxAdapter, now, captureObserver, binding } = input;
|
||||
const { sessionName, runtime, attachmentType, tmuxAdapter, now, captureObserver, binding, sentTexts, pastedText } = input;
|
||||
const sampledAt = (now ?? new Date()).toISOString();
|
||||
// P2: attempt identity frozen at entry, before any await. Early returns below
|
||||
// take no capture and are not observed.
|
||||
@@ -367,7 +403,7 @@ export async function probeSessionActivity(input: {
|
||||
try {
|
||||
const paneContent = await tmuxAdapter.capturePaneContent(sessionName, 20);
|
||||
const capturedAt = new Date().toISOString();
|
||||
const classification = classifyPaneActivity(paneContent ?? "");
|
||||
const classification = classifyPaneActivity(withoutSentEchoes(paneContent ?? "", sentTexts ?? [], pastedText));
|
||||
return observeProbe(captureSlot(paneContent, capturedAt, captureSeq), {
|
||||
state: mapPaneState(classification.state),
|
||||
reason: classification.reason,
|
||||
@@ -596,6 +632,9 @@ export class SessionTransport {
|
||||
private activityEndpointFile: () => { baseUrl: string; token: string } | null;
|
||||
private captureObserver?: CaptureObserverSink;
|
||||
private listProcesses?: NativeProcessLister;
|
||||
// Bounded recent echoes for the next send's readiness read. Replacement binding
|
||||
// invalidates them; no durable state or change to hook authority.
|
||||
private sentEchoes = new Map<string, { binding: string; texts: string[] }>();
|
||||
|
||||
constructor(deps: SessionTransportDeps) {
|
||||
this.db = deps.db;
|
||||
@@ -984,6 +1023,10 @@ export class SessionTransport {
|
||||
let preVerifyContent: string | null = null;
|
||||
const sessionMeta = this.getSessionMeta(sessionName);
|
||||
const runtime = sessionMeta.runtime;
|
||||
const echoBinding = JSON.stringify(sessionMeta);
|
||||
const previousEchoes = this.sentEchoes.get(sessionName);
|
||||
const sentTexts = previousEchoes?.binding === echoBinding ? previousEchoes.texts : [];
|
||||
if (previousEchoes?.binding !== echoBinding) this.sentEchoes.delete(sessionName);
|
||||
let runtimeAdvisory: string | undefined;
|
||||
const bindingChanged = () => JSON.stringify(this.getSessionMeta(sessionName)) !== JSON.stringify(sessionMeta);
|
||||
const changedRecipient = (sent = false): SendResult => ({ ok: false, sessionName, sent, reason: "target_runtime_conflict",
|
||||
@@ -1212,7 +1255,7 @@ export class SessionTransport {
|
||||
if (targetFailure) return targetFailure;
|
||||
const submitResult = await this.runStage(
|
||||
"session_transport.submit",
|
||||
() => this.tmuxAdapter.sendKeys(sessionName, ["C-m"]),
|
||||
() => this.tmuxAdapter.sendKeys(sessionName, ["Enter"]),
|
||||
(result) => result.ok ? "ok" : "failed",
|
||||
);
|
||||
if (!submitResult.ok) {
|
||||
@@ -1227,6 +1270,7 @@ export class SessionTransport {
|
||||
runtime,
|
||||
attachmentType: sessionMeta.attachmentType,
|
||||
timeoutMs: waitForIdleMs,
|
||||
sentTexts,
|
||||
binding: observed?.binding,
|
||||
});
|
||||
waitEvidence = {
|
||||
@@ -1246,20 +1290,51 @@ export class SessionTransport {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. OPR.0.4.1.10 — robust prompt/permission + mid-work guard on the DEFAULT path.
|
||||
// Runs the same detector previously reachable only via --wait-for-idle: fresh runtime-hook primary
|
||||
// (within the send-readiness window) + hardened capture-pane fallback. This closes the rig-send
|
||||
// prompt-injection footgun — a message can never select/submit/approve another agent's prompt by
|
||||
// default. OPR.0.4.3.28 correction + fast-follow: only POSITIVE picker/approval evidence
|
||||
// (needs_input) FAILS CLOSED (refuse, or an audited --dangerously-interact override). Every other
|
||||
// state now PROCEEDS with a non-blocking advisory: UNKNOWN (absent/stale/failed telemetry) and
|
||||
// RUNNING (mid-work, busy) both send-and-advise — busy/uncertain is not authority to block
|
||||
// communication. --force is a no-op on this path now (kept for back-compat) and never bypasses
|
||||
// the positive-picker guard (FR-4 — the footgun separation). The advisory is carried on the
|
||||
// success result via `warning` so the honest telemetry is surfaced.
|
||||
// Only positive picker/permission evidence refuses delivery. Unknown and busy remain
|
||||
// advisory. Reuse this rule at readiness and the two input boundaries; audit an
|
||||
// explicit override once, before it first permits input onto an observed prompt.
|
||||
let promptOverrideAudited = false;
|
||||
const promptFailure = (readiness: AgentActivity, sent = false): SendResult | null => {
|
||||
if (readiness.state !== "needs_input") return null;
|
||||
const effect = sent
|
||||
? "Text has already been pasted but was not submitted; Enter was not sent. Inspect the pane before trying again."
|
||||
: "No text was sent.";
|
||||
const refused = (reason: string, error: string): SendResult => ({
|
||||
ok: false, sessionName, reason, error: `${error} ${effect}`,
|
||||
...waitEvidence, activity: readiness, sent, outcome: "failed",
|
||||
});
|
||||
if (!opts?.dangerouslyInteract) {
|
||||
return refused("target_needs_input", `Refused: '${sessionName}' is at an interactive prompt (${readiness.reason}). A message must not select or approve it. To deliberately drive the prompt: rig send ${sessionName} "<text>" --dangerously-interact --reason "<why>".`);
|
||||
}
|
||||
if (!opts.reason || opts.reason.trim().length === 0) {
|
||||
return refused("dangerously_interact_requires_reason", "--dangerously-interact requires --reason explaining why the prompt is being driven.");
|
||||
}
|
||||
if (!promptOverrideAudited) {
|
||||
const audit = this.recordPromptOverride({
|
||||
sessionName, readiness, actorSession: opts.actorSession ?? null, overrideReason: opts.reason,
|
||||
});
|
||||
if (!audit.ok) {
|
||||
return refused("prompt_override_audit_unavailable", `Refused: --dangerously-interact requires an auditable override record, which could not be persisted (${audit.reason}).`);
|
||||
}
|
||||
promptOverrideAudited = true;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const checkPromptBoundary = async (sent: boolean): Promise<SendResult | null> => {
|
||||
const pane = await probeSessionActivity({
|
||||
sessionName, runtime, attachmentType: sessionMeta.attachmentType as "tmux" | "external_cli" | null,
|
||||
tmuxAdapter: this.tmuxAdapter, now: this.now(),
|
||||
captureObserver: this.captureObserver, binding: observed?.binding,
|
||||
sentTexts: sent ? [...sentTexts, text] : sentTexts,
|
||||
pastedText: sent ? text : undefined,
|
||||
});
|
||||
return promptFailure(pane, sent);
|
||||
};
|
||||
|
||||
let sendAdvisory: string | undefined;
|
||||
if (waitForIdleMs === undefined) {
|
||||
const readiness = await this.classifySendReadiness({
|
||||
sentTexts,
|
||||
sessionName,
|
||||
runtime,
|
||||
attachmentType: sessionMeta.attachmentType,
|
||||
@@ -1269,46 +1344,9 @@ export class SessionTransport {
|
||||
// Single state dispatch (B1 code-review fix): flattened so `unknown` ALWAYS attaches the advisory
|
||||
// regardless of whether --dangerously-interact was passed — the deliberate-override branch no
|
||||
// longer bypasses unknown handling.
|
||||
if (readiness.state === "needs_input") {
|
||||
// The POSITIVE picker/approval footgun. --dangerously-interact is the deliberate audited
|
||||
// override (reason required + an auditable record persisted BEFORE the send; fail closed if it
|
||||
// cannot be audited so an unauditable override never sends). Otherwise refuse with the
|
||||
// proceed-path. This is the ONLY state --dangerously-interact bypasses.
|
||||
if (opts?.dangerouslyInteract) {
|
||||
if (!opts.reason || opts.reason.trim().length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
reason: "dangerously_interact_requires_reason",
|
||||
error: "--dangerously-interact requires --reason explaining why the prompt is being driven. No text was sent.",
|
||||
};
|
||||
}
|
||||
const audit = this.recordPromptOverride({
|
||||
sessionName,
|
||||
readiness,
|
||||
actorSession: opts.actorSession ?? null,
|
||||
overrideReason: opts.reason,
|
||||
});
|
||||
if (!audit.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
reason: "prompt_override_audit_unavailable",
|
||||
activity: readiness,
|
||||
error: `Refused: --dangerously-interact requires an auditable override record, which could not be persisted (${audit.reason}). No text was sent.`,
|
||||
};
|
||||
}
|
||||
// audited → proceed to the send.
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
reason: "target_needs_input",
|
||||
activity: readiness,
|
||||
error: `Refused: '${sessionName}' is at an interactive prompt (${readiness.reason}). A message must not select or approve it. To deliberately drive the prompt: rig send ${sessionName} "<text>" --dangerously-interact --reason "<why>". No text was sent.`,
|
||||
};
|
||||
}
|
||||
} else if (readiness.state === "unknown") {
|
||||
const readinessFailure = promptFailure(readiness);
|
||||
if (readinessFailure) return observe(readinessFailure);
|
||||
if (readiness.state === "unknown") {
|
||||
// OPR.0.4.3.28 correction — INVERT the fail-closed-on-unknown default. Absent/stale/failed
|
||||
// telemetry is NOT positive picker evidence, so the send PROCEEDS. Diagnose the producer link
|
||||
// and carry it as a NON-blocking advisory (`warning` on the success result) — ALWAYS, whether
|
||||
@@ -1356,6 +1394,12 @@ export class SessionTransport {
|
||||
const targetFailure = await checkClaudeTarget();
|
||||
if (targetFailure) return observe(targetFailure);
|
||||
|
||||
// A fresh hook is advisory about the CURRENT pane. Check the existing positive
|
||||
// prompt detector immediately before paste, after all other readiness awaits.
|
||||
const beforePaste = await checkPromptBoundary(false);
|
||||
if (beforePaste) return observe(beforePaste);
|
||||
if (runtime === "claude-code" && bindingChanged()) return observe(changedRecipient());
|
||||
|
||||
// 3. Send text (paste)
|
||||
if (observed) observed.sentHash = hashSentText(text);
|
||||
const textResult = await this.runStage(
|
||||
@@ -1374,15 +1418,26 @@ export class SessionTransport {
|
||||
});
|
||||
}
|
||||
|
||||
this.sentEchoes.delete(sessionName);
|
||||
this.sentEchoes.set(sessionName, { binding: echoBinding, texts: [text, ...sentTexts].slice(0, 20) });
|
||||
if (this.sentEchoes.size > 256) this.sentEchoes.delete(this.sentEchoes.keys().next().value!);
|
||||
|
||||
// 4. Wait 200ms (spike-proven delay)
|
||||
await this.sleep(200);
|
||||
|
||||
if (runtime === "claude-code" && bindingChanged()) return observe(changedRecipient(true));
|
||||
|
||||
// 5. Submit (C-m)
|
||||
// A choice can appear during paste settling. Refuse Enter, honestly preserving
|
||||
// the already-pasted effect. Capture and input are not atomic: this narrows the
|
||||
// race, it cannot guarantee detection of a prompt appearing after this capture.
|
||||
const beforeSubmit = await checkPromptBoundary(true);
|
||||
if (beforeSubmit) return observe(beforeSubmit);
|
||||
if (runtime === "claude-code" && bindingChanged()) return observe(changedRecipient(true));
|
||||
|
||||
// 5. Submit the named Enter key (Ctrl+M differs in tmux extended-keys mode 2).
|
||||
const submitResult = await this.runStage(
|
||||
"session_transport.submit",
|
||||
() => this.tmuxAdapter.sendKeys(sessionName, ["C-m"]),
|
||||
() => this.tmuxAdapter.sendKeys(sessionName, ["Enter"]),
|
||||
(result) => result.ok ? "ok" : "failed",
|
||||
);
|
||||
if (!submitResult.ok) {
|
||||
@@ -1440,6 +1495,7 @@ export class SessionTransport {
|
||||
runtime: string | null;
|
||||
attachmentType: string | null;
|
||||
timeoutMs: number;
|
||||
sentTexts?: readonly string[];
|
||||
binding?: ObservedBinding;
|
||||
}): Promise<
|
||||
| { ok: true; activity: AgentActivity; waitedMs: number; attempts: number }
|
||||
@@ -1542,6 +1598,7 @@ export class SessionTransport {
|
||||
runtime: string | null;
|
||||
attachmentType: string | null;
|
||||
binding?: ObservedBinding;
|
||||
sentTexts?: readonly string[];
|
||||
}): Promise<AgentActivity> {
|
||||
const now = this.now();
|
||||
const hookActivity = this.agentActivityStore?.getLatestForNode({
|
||||
@@ -1556,7 +1613,7 @@ export class SessionTransport {
|
||||
hookActivity.evidenceSource === "runtime_hook" &&
|
||||
hookActivity.stale !== true
|
||||
) {
|
||||
// Fresh hook (within the 15s send window): authoritative for any state.
|
||||
// Fresh hook supplies readiness; positive pane vetoes still run at input boundaries.
|
||||
if (this.hookFreshForSend(hookActivity, now)) {
|
||||
return hookActivity;
|
||||
}
|
||||
@@ -1582,6 +1639,7 @@ export class SessionTransport {
|
||||
now,
|
||||
captureObserver: this.captureObserver,
|
||||
binding: input.binding,
|
||||
sentTexts: input.sentTexts,
|
||||
});
|
||||
if (paneVeto.state === "needs_input") {
|
||||
return paneVeto;
|
||||
@@ -1598,6 +1656,7 @@ export class SessionTransport {
|
||||
now,
|
||||
captureObserver: this.captureObserver,
|
||||
binding: input.binding,
|
||||
sentTexts: input.sentTexts,
|
||||
});
|
||||
// A Codex empty-composer placeholder is also on screen while Codex streams with its status
|
||||
// row hidden, so a placeholder-only idle verdict must not override a display-fresh (<5min)
|
||||
|
||||
@@ -25,6 +25,8 @@ function terminal(capture?:ShadowCapture) {
|
||||
return {db,tmux,transport,mutate:()=>{pane="%99";occupant="new";},setHook:(fn:()=>void)=>{hook=fn;},counts:()=>({probes,captures,pastes,enters})};
|
||||
}
|
||||
describe("disabled-by-default production observer ports",()=>{
|
||||
// Each verified send observes readiness, pre-paste, pre-Enter, and its result.
|
||||
// The three probe captures plus verification's before/after captures total five.
|
||||
it("stop disables capture immediately, flushes the retained active and queued rows once, and preserves ordinary send",async()=>{
|
||||
const rows:Observation[]=[];let release!:()=>void, entered!:()=>void, closes=0;
|
||||
const started=new Promise<void>(r=>{entered=r;});
|
||||
@@ -32,12 +34,12 @@ describe("disabled-by-default production observer ports",()=>{
|
||||
const f=terminal(shadow);await f.transport.send("seat@rig","hello world",{verify:true});
|
||||
const draining=shadow.drain();await started;
|
||||
await f.transport.send("seat@rig","hello world",{verify:true});
|
||||
expect(shadow.status().observer.recorded).toBe(4);
|
||||
expect(shadow.status().observer.recorded).toBe(8);
|
||||
const stop=shadow.stop();expect(shadow.status().enabled).toBe(false);
|
||||
expect((await f.transport.send("seat@rig","hello world",{verify:true})).outcome).toBe("delivered");
|
||||
expect(shadow.status().observer.recorded).toBe(4);release();await draining;
|
||||
const status=await stop;expect(status.observer.queued).toBe(0);expect(rows).toHaveLength(4);expect(closes).toBe(1);
|
||||
expect(status.sink).toMatchObject({completedRecords:4,dropped:0,stopped:true});
|
||||
expect(shadow.status().observer.recorded).toBe(8);release();await draining;
|
||||
const status=await stop;expect(status.observer.queued).toBe(0);expect(rows).toHaveLength(8);expect(closes).toBe(1);
|
||||
expect(status.sink).toMatchObject({completedRecords:8,dropped:0,stopped:true});
|
||||
expect(await shadow.stop()).toEqual(status);expect(closes).toBe(1);
|
||||
});
|
||||
it("public stop requires an actor, drains retained observations, and cannot enable an absent collector",async()=>{
|
||||
@@ -46,7 +48,7 @@ describe("disabled-by-default production observer ports",()=>{
|
||||
const app=new Hono();app.use("*",async(c,next)=>{c.set("shadowCapture" as never,shadow);await next();});app.route("/",projectsRoutes());
|
||||
expect((await app.request("/shadow/stop",{method:"POST"})).status).toBe(400);expect(shadow.status().enabled).toBe(true);
|
||||
const response=await app.request("/shadow/stop",{method:"POST",headers:{"x-openrig-session":"fixture@rig"}});
|
||||
expect(await response.json()).toMatchObject({enabled:false,sink:{completedRecords:2,stopped:true}});expect(rows).toHaveLength(2);
|
||||
expect(await response.json()).toMatchObject({enabled:false,sink:{completedRecords:4,stopped:true}});expect(rows).toHaveLength(4);
|
||||
const absent=new Hono().route("/",projectsRoutes());
|
||||
expect(await(await absent.request("/shadow/stop",{method:"POST",headers:{"x-openrig-session":"fixture@rig"}})).json()).toEqual({enabled:false,error:null});
|
||||
});
|
||||
@@ -56,7 +58,7 @@ describe("disabled-by-default production observer ports",()=>{
|
||||
expect(configureShadowCapture(JSON.stringify({...config,destination:"relative"})).capture).toBeUndefined();
|
||||
const valid=configureShadowCapture(JSON.stringify(config));expect(valid.capture?.status().sink.reservedRecords).toBe(0);
|
||||
const f=terminal();expect((await f.transport.send("seat@rig","hello world",{verify:true})).outcome).toBe("delivered");
|
||||
expect(f.counts()).toMatchObject({captures:3,pastes:1});
|
||||
expect(f.counts()).toMatchObject({captures:5,pastes:1,enters:1});
|
||||
expect(inventoryCaptureOptions(f.db,undefined)).toEqual({});
|
||||
});
|
||||
it("transport entry snapshots node/occupant/pane before awaits and uses only existing captures",async()=>{
|
||||
@@ -65,8 +67,11 @@ describe("disabled-by-default production observer ports",()=>{
|
||||
const f=terminal(shadow);f.setHook(f.mutate);
|
||||
expect((await f.transport.send("seat@rig","hello world",{verify:true})).outcome).toBe("delivered");
|
||||
expect(rows).toHaveLength(0);await shadow.drain();
|
||||
expect(f.counts()).toMatchObject({captures:3,pastes:1});
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(f.counts()).toMatchObject({captures:5,pastes:1,enters:1});
|
||||
const plain=terminal();plain.setHook(plain.mutate);
|
||||
expect((await plain.transport.send("seat@rig","hello world",{verify:true})).outcome).toBe("delivered");
|
||||
expect(f.counts()).toEqual(plain.counts()); // observing adds no capture or input
|
||||
expect(rows.map(row=>row.seam)).toEqual(["probe_activity","probe_activity","probe_activity","send_verify"]);
|
||||
for(const row of rows)expect(row.binding).toEqual({sessionName:"seat@rig",nodeId:"node",occupant:"old",pane:"%7"});
|
||||
expect(rows.find(x=>x.seam==="send_verify")!.regexResult).toMatchObject({outcome:"delivered"});
|
||||
});
|
||||
@@ -90,7 +95,7 @@ describe("disabled-by-default production observer ports",()=>{
|
||||
const drain=shadow.drain();
|
||||
if(kind==="slow")await started;
|
||||
const result=await f.transport.send("seat@rig","hello world",{verify:true});
|
||||
expect(result.outcome).toBe("delivered");expect(f.counts()).toMatchObject({pastes:2,captures:6});
|
||||
expect(result.outcome).toBe("delivered");expect(f.counts()).toMatchObject({pastes:2,enters:2,captures:10});
|
||||
if(kind==="slow"){
|
||||
expect(shadow.status().observer.drainingBytes).toBeGreaterThan(0);
|
||||
// One pending drain only; overflow on the next send cannot spawn another sink call.
|
||||
@@ -103,7 +108,7 @@ describe("disabled-by-default production observer ports",()=>{
|
||||
await drain;
|
||||
if(kind==="error")expect(shadow.status()).toMatchObject({sink:{errors:1,stopped:true},observer:{consumerFailures:1}});
|
||||
if(kind==="full")expect(shadow.status().sink).toMatchObject({reservedRecords:1,dropped:1});
|
||||
if(kind==="bytes")expect(shadow.status().observer).toMatchObject({recorded:0,dropped:4});
|
||||
if(kind==="bytes")expect(shadow.status().observer).toMatchObject({recorded:0,dropped:8});
|
||||
});
|
||||
it("HTTP drain is separate, cannot enable capture, requires an actor, and reports sink errors",async()=>{
|
||||
const shadow=new ShadowCapture(config,async()=>{throw Error("private destination unavailable");});
|
||||
@@ -122,7 +127,7 @@ describe("disabled-by-default production observer ports",()=>{
|
||||
await terminal(capture).transport.send("seat@rig","hello world",{verify:true});await capture.drain();
|
||||
expect(fs.statSync(destination).mode & 0o777).toBe(0o600);
|
||||
const bytes=fs.readFileSync(destination,"utf8");expect(bytes.trim().split("\n")).toHaveLength(1);
|
||||
expect(capture.status().sink).toMatchObject({reservedRecords:1,completedRecords:1,dropped:1,stopped:true});
|
||||
expect(capture.status().sink).toMatchObject({reservedRecords:1,completedRecords:1,dropped:3,stopped:true});
|
||||
const existing=new ShadowCapture({...config,destination});
|
||||
await terminal(existing).transport.send("seat@rig","hello world",{verify:true});await existing.drain();
|
||||
expect(existing.status().sink.errors).toBe(1);expect(fs.readFileSync(destination,"utf8")).toBe(bytes);
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("S01/S02 P2 capture observer", () => {
|
||||
post: { state: "captured", content: "before\nhello world\n❯ " },
|
||||
regexResult: { ok: true, outcome: "delivered", verified: true },
|
||||
});
|
||||
expect(obs!.seq).toBe(2); // seq 1 is this send's readiness probe
|
||||
expect(obs!.seq).toBe(4); // readiness, pre-paste and pre-Enter probes precede the send result
|
||||
expect(typeof obs!.attemptId).toBe("string");
|
||||
expect(Object.isFrozen(obs)).toBe(true);
|
||||
expect(Object.isFrozen(obs!.binding)).toBe(true);
|
||||
@@ -138,7 +138,7 @@ describe("S01/S02 P2 capture observer", () => {
|
||||
expect(obs!.pre).toEqual({ state: "not_requested" });
|
||||
expect(obs!.post).toEqual({ state: "not_requested" });
|
||||
expect(obs!.regexResult).toEqual({ ok: true });
|
||||
expect(t.calls.capture).toBe(1); // only the pre-existing readiness-probe capture; P2 adds none
|
||||
expect(t.calls.capture).toBe(3); // readiness plus both input boundaries; observing adds none
|
||||
});
|
||||
|
||||
it("a failed paste leaves the post capture not_reached", async () => {
|
||||
@@ -173,10 +173,9 @@ describe("S01/S02 P2 capture observer", () => {
|
||||
} });
|
||||
const o = new CaptureObserver();
|
||||
await transport(t.adapter, o).send("dev-impl@obs-rig", "x", { waitForIdleMs });
|
||||
const [probe, send] = await drainAll(o);
|
||||
const observations = await drainAll(o);
|
||||
const expected = { sessionName: "dev-impl@obs-rig", nodeId: node.id, occupant, pane: "%7" };
|
||||
expect(probe!.binding).toEqual(expected);
|
||||
expect(send!.binding).toEqual(expected);
|
||||
for (const observation of observations) expect(observation.binding).toEqual(expected);
|
||||
});
|
||||
|
||||
it("capture sequence and time survive sends completing in reverse order", async () => {
|
||||
@@ -278,26 +277,26 @@ describe("S01/S02 P2 capture observer", () => {
|
||||
expect(other.result).toEqual(baseline.result);
|
||||
expect(other.calls).toEqual(baseline.calls);
|
||||
}
|
||||
expect(full.stats()).toMatchObject({ recorded: 1, dropped: 2, queued: 1 }); // probe + send both dropped
|
||||
expect(full.stats()).toMatchObject({ recorded: 1, dropped: 4, queued: 1 }); // probe + send both dropped
|
||||
});
|
||||
|
||||
it("a slow or failing consumer never blocks recording and nothing is replayed", async () => {
|
||||
seed();
|
||||
const o = new CaptureObserver();
|
||||
const tr = transport(terminal().adapter, o);
|
||||
await tr.send("dev-impl@obs-rig", "one"); // seq 1 probe, 2 send
|
||||
await tr.send("dev-impl@obs-rig", "one"); // seq 1–3 probes, 4 send
|
||||
let release!: () => void;
|
||||
const slow = o.drain(() => new Promise<void>((r) => { release = r; }));
|
||||
expect(await o.drain(() => {})).toBe(0); // concurrent drain refused, not awaited
|
||||
await tr.send("dev-impl@obs-rig", "two"); // seq 3, 4: recording continues while the consumer is stuck
|
||||
expect(o.stats().queued).toBe(2);
|
||||
await tr.send("dev-impl@obs-rig", "two"); // seq 5–8: recording continues while the consumer is stuck
|
||||
expect(o.stats().queued).toBe(4);
|
||||
release();
|
||||
expect(await slow).toBe(2);
|
||||
expect(await slow).toBe(4);
|
||||
expect(await o.drain(() => { throw new Error("consumer down"); })).toBe(0);
|
||||
expect(o.stats()).toMatchObject({ drained: 2, consumerFailures: 1, consumerFailedObservations: 2, queued: 0 });
|
||||
expect(o.stats()).toMatchObject({ drained: 4, consumerFailures: 1, consumerFailedObservations: 4, queued: 0 });
|
||||
await tr.send("dev-impl@obs-rig", "three");
|
||||
const later = await drainAll(o);
|
||||
expect(later.map((x) => x.seq)).toEqual([5, 6]); // the failed batch (3, 4) is not replayed
|
||||
expect(later.map((x) => x.seq)).toEqual([9, 10, 11, 12]); // the failed batch (5–8) is not replayed
|
||||
});
|
||||
|
||||
it("sequence numbers are ordered across observations", async () => {
|
||||
@@ -306,10 +305,10 @@ describe("S01/S02 P2 capture observer", () => {
|
||||
const tr = transport(terminal().adapter, o);
|
||||
for (const text of ["a", "b", "c"]) await tr.send("dev-impl@obs-rig", text);
|
||||
const all = await drainAll(o);
|
||||
expect(all.map((x) => x.seq)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
expect(all.map((x) => x.seam)).toEqual(["probe_activity", "send_verify", "probe_activity", "send_verify", "probe_activity", "send_verify"]);
|
||||
expect(all.map((x) => x.seq)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
expect(all.map((x) => x.seam)).toEqual(Array(3).fill(["probe_activity", "probe_activity", "probe_activity", "send_verify"]).flat());
|
||||
// The readiness probe is its own attempt; it never borrows the send's identity.
|
||||
expect(new Set(all.map((x) => x.attemptId)).size).toBe(6);
|
||||
expect(new Set(all.map((x) => x.attemptId)).size).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// OPR.0.4.1.10 — rig send interactive-prompt / permission-block guard: KEYSTONE regression.
|
||||
// Reproduces the 2026-06-20 footgun (a peer rig-send submitted an open AskUserQuestion default and
|
||||
// shipped a release) and proves it is impossible by default. K-1..K-6 from the impl-prd plus the
|
||||
// shipped a release) and checks refusal of a positively observed prompt. K-1..K-6 from the impl-prd plus the
|
||||
// guard-required amendment tests (audit all-or-nothing, danger+wait rejection, send-readiness
|
||||
// freshness fallback). Detector covered on BOTH the fresh-runtime-hook path and the capture-pane
|
||||
// fallback (Codex's sole prompt guard — exact render from qa-codex-approval-render-research-20260627).
|
||||
@@ -104,9 +104,11 @@ describe("OPR.0.4.1.10 rig send prompt/permission guard (keystone)", () => {
|
||||
withBus?: boolean;
|
||||
now?: () => Date;
|
||||
activityEndpointFile?: () => { baseUrl: string; token: string } | null;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}) {
|
||||
return new SessionTransport({
|
||||
db, rigRepo, sessionRegistry, tmuxAdapter: tmux, agentActivityStore,
|
||||
...(opts?.sleep ? { sleep: opts.sleep } : {}),
|
||||
...(opts?.withBus === false ? {} : { eventBus }),
|
||||
...(opts?.now ? { now: opts.now } : {}),
|
||||
...(opts?.activityEndpointFile ? { activityEndpointFile: opts.activityEndpointFile } : {}),
|
||||
@@ -169,7 +171,7 @@ describe("OPR.0.4.1.10 rig send prompt/permission guard (keystone)", () => {
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(sendText).toHaveBeenCalledWith("dev-impl@my-rig", "1");
|
||||
expect(sendKeys).toHaveBeenCalledWith("dev-impl@my-rig", ["C-m"]);
|
||||
expect(sendKeys).toHaveBeenCalledWith("dev-impl@my-rig", ["Enter"]);
|
||||
const events = overrideEvents();
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
@@ -607,7 +609,7 @@ describe("OPR.0.4.1.10 rig send prompt/permission guard (keystone)", () => {
|
||||
expect(sendText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("AMEND: a hook fresh within the send window (idle) is authoritative and the send proceeds", async () => {
|
||||
it("a fresh idle hook is vetoed by a positive current-pane prompt", async () => {
|
||||
const fixedNow = new Date("2026-06-27T12:00:00.000Z");
|
||||
agentActivityStore.recordHookEvent({
|
||||
runtime: "claude-code", sessionName: "dev-impl@my-rig", hookEvent: "Stop",
|
||||
@@ -616,9 +618,9 @@ describe("OPR.0.4.1.10 rig send prompt/permission guard (keystone)", () => {
|
||||
const { sendText } = spies();
|
||||
const t = makeTransport(mockTmux({ capturePaneContent: async () => SHIP_PROMPT, sendText }), { now: () => fixedNow });
|
||||
const r = await t.send("dev-impl@my-rig", "hi");
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.activity === undefined || r.activity?.evidenceSource === "runtime_hook").toBe(true);
|
||||
expect(sendText).toHaveBeenCalled();
|
||||
expect(r).toMatchObject({ ok: false, reason: "target_needs_input", sent: false });
|
||||
expect(r.activity?.evidenceSource).toBe("pane_heuristic");
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// FR-1c: a permission question with no visible selector is still detected (Codex/Claude robustness).
|
||||
@@ -700,4 +702,145 @@ describe("OPR.0.4.1.10 rig send prompt/permission guard (keystone)", () => {
|
||||
expect(forced.warning).toContain("busy is advisory");
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
});
|
||||
describe("input-boundary rechecks (#498)", () => {
|
||||
const choice = "Choose a color\n❯ 1. RED\n 2. BLUE";
|
||||
const idle = "❯ ";
|
||||
|
||||
it.each(["codex", "claude", "plain"])("submits own echoed text and subsequent sends (%s)", async (layout) => {
|
||||
let draft = "";
|
||||
const history: string[] = [];
|
||||
const pane = () => [
|
||||
...history.map((text) => `> ${text}`),
|
||||
layout === "claude" ? "────────────────────" : "",
|
||||
`❯ ${draft}`,
|
||||
layout === "claude" ? "────────────────────" : "",
|
||||
layout === "codex" ? "gpt-5.1 high · Context [42% used]" : layout === "claude" ? "⏵⏵ accept edits on" : "",
|
||||
].join("\n");
|
||||
const sendText = vi.fn(async (_target: string, text: string) => { draft = text; return { ok: true as const }; });
|
||||
const sendKeys = vi.fn(async () => { history.push(draft); draft = ""; return { ok: true as const }; });
|
||||
const transport = makeTransport(mockTmux({ capturePaneContent: async () => pane(), sendText, sendKeys }), { sleep: async () => {} });
|
||||
for (const text of ["hello", "Please check: Do you want to run the tests?", "A note\nDo you want to run the tests?\nEnd of note", "1. RED\n2. BLUE", "second send"]) {
|
||||
const first = await transport.send("dev-impl@my-rig", text);
|
||||
expect(first.ok).toBe(true);
|
||||
const second = await transport.send("dev-impl@my-rig", "follow up", { waitForIdleMs: 1000 });
|
||||
expect(second.ok).toBe(true);
|
||||
}
|
||||
expect(sendText).toHaveBeenCalledTimes(10);
|
||||
expect(sendKeys).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it.each(["choice", "permission", "foreign draft", "changed draft"])("does not erase %s beside its own echo", async (kind) => {
|
||||
let pane = idle;
|
||||
const text = "Please check: Do you want to run the tests?";
|
||||
const sendText = vi.fn(async () => { pane = `❯ ${text}\n` + (
|
||||
kind === "choice" ? choice : kind === "permission" ? "Do you want to allow this operation?" :
|
||||
kind === "foreign draft" ? "❯ someone's input\n⏵⏵ accept edits on" : "❯ Please check: Do you want to run different tests?\n⏵⏵ accept edits on"
|
||||
); return { ok: true as const }; });
|
||||
const sendKeys = vi.fn(async () => ({ ok: true as const }));
|
||||
const result = await makeTransport(mockTmux({ capturePaneContent: async () => pane, sendText, sendKeys }), { sleep: async () => {} })
|
||||
.send("dev-impl@my-rig", text);
|
||||
expect(result).toMatchObject({ ok: false, reason: "target_needs_input", sent: true, outcome: "failed" });
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["wrapped", "placeholder"])("recognizes its own %s composer rendering", async (style) => {
|
||||
let pane = idle;
|
||||
const text = "Please check: Do you want to run the tests?\nA second line";
|
||||
const sendText = vi.fn(async () => { pane = style === "wrapped"
|
||||
? "❯ Please check: Do you want to\n run the tests?\nA second line\n⏵⏵ accept edits on"
|
||||
: "❯ [Pasted text #1 +2 lines]\n⏵⏵ accept edits on";
|
||||
return { ok: true as const }; });
|
||||
const sendKeys = vi.fn(async () => ({ ok: true as const }));
|
||||
const result = await makeTransport(mockTmux({ capturePaneContent: async () => pane, sendText, sendKeys }), { sleep: async () => {} })
|
||||
.send("dev-impl@my-rig", text);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(sendKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(["first read", "failed paste", "replacement binding"])("does not exempt text without a matching successful paste (%s)", async (condition) => {
|
||||
const text = "Do you want to run the tests?";
|
||||
let pane = idle;
|
||||
const sendText = vi.fn(async () => ({ ok: condition !== "failed paste", message: "fixture failure" }));
|
||||
const sendKeys = vi.fn(async () => ({ ok: true as const }));
|
||||
const transport = makeTransport(mockTmux({ capturePaneContent: async () => pane, sendText, sendKeys }), { sleep: async () => {} });
|
||||
if (condition !== "first read") await transport.send("dev-impl@my-rig", text);
|
||||
if (condition === "replacement binding") db.prepare("UPDATE sessions SET resume_token = 'replacement' WHERE session_name = ?").run("dev-impl@my-rig");
|
||||
pane = `❯ ${text}\n⏵⏵ accept edits on`;
|
||||
sendText.mockClear(); sendKeys.mockClear();
|
||||
const result = await transport.send("dev-impl@my-rig", text);
|
||||
expect(result).toMatchObject({ ok: false, reason: "target_needs_input", sent: false });
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["", "\n"])("does not erase a selector that repeats the sent text (gap=%j)", async (gap) => {
|
||||
let pane = idle;
|
||||
const sendText = vi.fn(async () => { pane = `Choose a color\n❯ 1. RED\n${gap} 2. BLUE`; return { ok: true as const }; });
|
||||
const sendKeys = vi.fn(async () => ({ ok: true as const }));
|
||||
const result = await makeTransport(mockTmux({ capturePaneContent: async () => pane, sendText, sendKeys }), { sleep: async () => {} })
|
||||
.send("dev-impl@my-rig", "1. RED");
|
||||
expect(result).toMatchObject({ ok: false, reason: "target_needs_input", sent: true });
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["Stop", "UserPromptSubmit"])("fresh %s hook cannot hide a visible choice", async (hookEvent) => {
|
||||
agentActivityStore.recordHookEvent({ runtime: "claude-code", sessionName: "dev-impl@my-rig", hookEvent });
|
||||
const { sendText, sendKeys } = spies();
|
||||
const result = await makeTransport(mockTmux({ capturePaneContent: async () => choice, sendText, sendKeys }))
|
||||
.send("dev-impl@my-rig", "note");
|
||||
expect(result).toMatchObject({ ok: false, reason: "target_needs_input", sent: false });
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([false, true])("catches a choice after the readiness read (verify=%s)", async (verify) => {
|
||||
let captures = 0;
|
||||
const { sendText, sendKeys } = spies();
|
||||
const result = await makeTransport(mockTmux({
|
||||
capturePaneContent: async () => ++captures === 1 ? idle : choice, sendText, sendKeys,
|
||||
})).send("dev-impl@my-rig", "note", { verify });
|
||||
expect(result).toMatchObject({ ok: false, reason: "target_needs_input", sent: false });
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([undefined, 1000])("catches a choice during paste settling, truthfully (wait=%s)", async (waitForIdleMs) => {
|
||||
let pane = idle;
|
||||
const { sendText, sendKeys } = spies();
|
||||
const result = await makeTransport(mockTmux({ capturePaneContent: async () => pane, sendText, sendKeys }), {
|
||||
sleep: async () => { pane = choice; },
|
||||
}).send("dev-impl@my-rig", "note", { waitForIdleMs, verify: true });
|
||||
expect(result).toMatchObject({ ok: false, reason: "target_needs_input", sent: true, outcome: "failed" });
|
||||
expect(result.error).toContain("already been pasted");
|
||||
expect(result.error).toContain("not submitted");
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["unknown", "busy", "capture-error"])("keeps %s advisory through both boundaries", async (state) => {
|
||||
const { sendText, sendKeys } = spies();
|
||||
const result = await makeTransport(mockTmux({ capturePaneContent: async () => {
|
||||
if (state === "capture-error") throw new Error("fixture unavailable");
|
||||
return state === "busy" ? "Working… esc to interrupt" : "unrecognized view";
|
||||
}, sendText, sendKeys }), { sleep: async () => {} }).send("dev-impl@my-rig", "note");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.warning).toBeTruthy();
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendKeys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([false, true])("requires a durable override at a late choice (audit available=%s)", async (withBus) => {
|
||||
let pane = idle;
|
||||
const { sendText, sendKeys } = spies();
|
||||
const result = await makeTransport(mockTmux({ capturePaneContent: async () => pane, sendText, sendKeys }), {
|
||||
withBus, sleep: async () => { pane = choice; },
|
||||
}).send("dev-impl@my-rig", "note", { dangerouslyInteract: true, reason: "deliberate color fixture" });
|
||||
expect(result.ok).toBe(withBus);
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendKeys).toHaveBeenCalledTimes(withBus ? 1 : 0);
|
||||
expect(overrideEvents()).toHaveLength(withBus ? 1 : 0);
|
||||
if (!withBus) expect(result).toMatchObject({ reason: "prompt_override_audit_unavailable", sent: true });
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -384,8 +384,8 @@ describe("SessionTransport", () => {
|
||||
return { rig, node, session };
|
||||
}
|
||||
|
||||
// Test 1: send calls sendText -> delay -> sendKeys C-m
|
||||
it("send calls sendText then sendKeys C-m with delay", async () => {
|
||||
// Test 1: send calls sendText -> delay -> sendKeys Enter
|
||||
it("send calls sendText then sendKeys Enter with delay", async () => {
|
||||
seedCanonicalRig();
|
||||
const callOrder: string[] = [];
|
||||
const tmux = mockTmux({
|
||||
@@ -396,7 +396,7 @@ describe("SessionTransport", () => {
|
||||
|
||||
const result = await transport.send("dev-impl@my-rig", "hello");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(callOrder).toEqual(["sendText", "sendKeys:C-m"]);
|
||||
expect(callOrder).toEqual(["sendText", "sendKeys:Enter"]);
|
||||
});
|
||||
|
||||
// Test 2: send to canonical session name resolves correctly
|
||||
@@ -435,8 +435,8 @@ describe("SessionTransport", () => {
|
||||
expect(result.error).toContain("rig ps");
|
||||
});
|
||||
|
||||
// Test 5: send where sendKeys C-m fails returns "text visible but not submitted"
|
||||
it("send where C-m fails returns submit_failed with guidance", async () => {
|
||||
// Test 5: send where sendKeys Enter fails returns "text visible but not submitted"
|
||||
it("send where Enter fails returns submit_failed with guidance", async () => {
|
||||
seedCanonicalRig();
|
||||
const tmux = mockTmux({
|
||||
sendKeys: async () => ({ ok: false, code: "session_not_found", message: "session died" }),
|
||||
@@ -623,7 +623,7 @@ describe("SessionTransport", () => {
|
||||
expect(result.attempts).toBe(2);
|
||||
expect(result.activity?.state).toBe("idle");
|
||||
expect(sendTextSpy).toHaveBeenCalledWith("dev-impl@my-rig", "hello");
|
||||
expect(callOrder).toEqual(["capture", "capture", "sendText", "sendKeys"]);
|
||||
expect(callOrder).toEqual(["capture", "capture", "capture", "sendText", "capture", "sendKeys"]);
|
||||
});
|
||||
|
||||
it("send with wait-for-idle waits through current Claude thinking evidence and sends after idle", async () => {
|
||||
@@ -677,7 +677,7 @@ describe("SessionTransport", () => {
|
||||
expect(result.attempts).toBe(2);
|
||||
expect(result.activity?.state).toBe("idle");
|
||||
expect(sendTextSpy).toHaveBeenCalledWith("dev-impl@my-rig", "hello");
|
||||
expect(callOrder).toEqual(["capture", "capture", "sendText", "sendKeys"]);
|
||||
expect(callOrder).toEqual(["capture", "capture", "capture", "sendText", "capture", "sendKeys"]);
|
||||
});
|
||||
|
||||
it("send with wait-for-idle times out on running activity without sending text", async () => {
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("SessionTransport submitOnly — the guarded bare-Enter retry", () => {
|
||||
|
||||
const STAGED_PIECE = "# World from primitives\n\nThe seat learns the world by composing…";
|
||||
|
||||
it("presses C-m exactly once, types NOTHING, when the pane shows the expected staged text", async () => {
|
||||
it("presses Enter exactly once, types NOTHING, when the pane shows the expected staged text", async () => {
|
||||
const sendText = vi.fn(async () => ({ ok: true as const }));
|
||||
const sendKeys = vi.fn(async () => ({ ok: true as const }));
|
||||
const transport = makeTransport(mockTmux({
|
||||
@@ -95,7 +95,7 @@ describe("SessionTransport submitOnly — the guarded bare-Enter retry", () => {
|
||||
expect(res.submitOnly).toBe(true);
|
||||
expect(sendText).not.toHaveBeenCalled(); // nothing typed — ever
|
||||
expect(sendKeys).toHaveBeenCalledTimes(1);
|
||||
expect(sendKeys).toHaveBeenCalledWith("dev-impl@my-rig", ["C-m"]);
|
||||
expect(sendKeys).toHaveBeenCalledWith("dev-impl@my-rig", ["Enter"]);
|
||||
});
|
||||
|
||||
it("REFUSES (staged_mismatch) when the pane shows something else — a bare Enter at a permission prompt would approve it", async () => {
|
||||
|
||||
Reference in new issue
Block a user