fix: check expected native conversation at record and input boundaries

This commit is contained in:
dev-guard committed 2026-10-30 08:52:07 +00:00
1 parent 12e29860ad
commit eb61a32abe
9 files changed
+302 -24

No files matched your search

+43
View File
@@ -417,3 +417,46 @@ retain `follow_view_required` in a 200 `ok:false` body, as before. A changed
preview remains `preview_changed` (409). During automatic refresh a missing or
ambiguous scope retains the existing tiles and names the source problem in the
explanation strip; refresh or reopen it under the current unambiguous key.
## Reading or sending to an expected native conversation
An application that selects an original Claude Code or Codex conversation can opt
into an expected-identity check. The identity is the exact tuple `nodeId`, canonical
`sessionName`, `runtime` (`claude-code` or `codex`) and native `nativeId`. A registry
resume token by itself is not a current-process identity observation.
Read `GET /api/sessions/:sessionName/generation-record-expected` with query fields
`expectedNodeId`, `expectedRuntime`, `expectedNativeId` and optional `sinceBytes`.
It resolves the verified current occupant and native process, opens that native
record, checks its identity, and returns identity and bounded suffix bytes from
the same opened file. Its existing byte cursor and `generationId` semantics match
`generation-record`. Success adds `identityContract:1` and `checkedIdentity`, the
exact expected tuple. Check both before displaying the suffix as that conversation.
Send `POST /api/transport/send-expected` with the ordinary send body and a required
`expectedIdentity` object containing those four fields. The check runs again after
buffer preparation immediately before paste, and before Enter after any wait.
A retained input stores the constraint in its outbox receipt; committed dispatch
and reuse of its delivery ID cannot select a different original conversation or
omit the stored constraint. Delivery IDs still do not deduplicate explicit input.
Success adds `identityContract:1` and `checkedIdentity`. Retained is not sent;
`checkedIdentity` is an observation at an input boundary, not model consumption.
If replacement is observed after paste, the result reports `inputPhase:"written"`
and `sent:true`, with Enter refused. Inspect that input before deciding what to do.
Malformed expectations return 400 (`invalid_expected_identity`). A different
observed identity returns 409 (`expected_identity_mismatch`); a missing or
unverified identity returns 409 (`expected_identity_unavailable`). Conflicting
reuse of a delivery ID returns 409 (`delivery_identity_conflict`). No conditional
operation falls back to the replacement. Use these dedicated paths when detecting
support: older daemons return 404 instead of silently ignoring an unknown field.
The ordinary read and send endpoints keep their existing behavior for callers
that do not opt in.
This is a finite process/record observation followed by a terminal write; tmux has
no atomic native-conversation compare-and-write operation. A native conversation
change after the final observation remains a race. The check does not turn a
terminal attachment into a conversation pin: existing terminal viewers follow
managed pane/session bindings and may display their replacement. Applications
must keep that attachment limit separate from checked record reads and sends.
+2 -1
View File
@@ -1021,11 +1021,12 @@ export class TmuxAdapter {
}
}
async sendKeys(target: string, keys: string[], beforeInput?: () => void): Promise<TmuxResult> {
async sendKeys(target: string, keys: string[], beforeInput?: () => void, options?: { beforeKeys?: () => Promise<void> }): Promise<TmuxResult> {
return this.guardedInput(target, async (pane, beforeWrite) => {
const identity = this.freshManaged.get(target)?.nodeId ?? target;
const submit = keys.some(key => key === "Enter" || key === "C-m");
if (this.deliveryGuard?.needsInputCheck(identity)) await this.deliveryGuard.beforeInput(identity, submit);
if (options?.beforeKeys) await options.beforeKeys();
beforeWrite(); beforeInput?.();
const result = await this.sendKeysUnchecked(pane, keys);
if (result.ok && submit) this.deliveryGuard?.noteSubmitted(identity);
@@ -10,7 +10,7 @@ export interface GuardedDeliverySummary {
}
/** Historical attempts contain outcomes, never executable options or callbacks. */
interface Receipt { ids: string[]; result?: SendResult }
interface Receipt { ids: string[]; result?: SendResult; expectedIdentity?: import("./expected-conversation.js").ExpectedConversation }
type Deliver = (session: string, text: string, opts: SendOpts) => Promise<SendResult>;
/** Applicability needs the intended queue row and recipient, not a saved delivery receipt. */
export type QueueWakeFacts = Pick<OutboxEntry, "outboxId" | "auditPointer" | "destinationSession">;
@@ -42,7 +42,7 @@ export class DraftAwareDelivery {
/** The existing per-seat lease serializes requests. Each current request gets an attempt, not an old result. */
async send(session: string, text: string, opts: SendOpts, ids: string[]): Promise<SendResult> {
if (!ids.length || new Set(ids).size !== ids.length) throw new DeliveryGuardError("invalid_delivery_ids", "Distinct delivery IDs are required.");
const pending = this.attempt(session, text, opts, { ids });
const pending = this.attempt(session, text, opts, { ids, ...(opts.expectedIdentity ? { expectedIdentity: opts.expectedIdentity } : {}) });
this.sends.add(pending);
try { return await pending; }
finally { this.sends.delete(pending); }
@@ -69,7 +69,10 @@ export class DraftAwareDelivery {
}
})();
recorded = true;
} catch { /* Bookkeeping cannot veto input; report its absence with the actual result. */ }
} catch (error) {
if (opts.expectedIdentity) throw error; // A conditional send cannot lose its durable constraint and still type.
/* Legacy bookkeeping cannot veto input; report its absence with the actual result. */
}
try {
await this.guard.beforeInput(session);
result = await this.deliver(session, text, { ...opts,
@@ -0,0 +1,88 @@
import { statSync } from "node:fs";
import type { SessionRegistry } from "./session-registry.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import type { ContextUsageStore } from "./context-usage-store.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
import { ProcessCensus } from "./process-census.js";
import { CodexThreadIdResolver } from "./codex-thread-id.js";
import { resolveIdentityVerifiedClaudeRecord, resolveLiveCodexThreadId } from "./model-divergence/current-generation-record.js";
import { parseSqliteUtcMs } from "./sqlite-time.js";
import { DeliveryGuardError } from "./seat-delivery-guard.js";
export interface ExpectedConversation {
nodeId: string;
sessionName: string;
runtime: "claude-code" | "codex";
nativeId: string;
}
const census = new ProcessCensus({ freshnessMs: 0 });
const threads = new CodexThreadIdResolver({ codexHome: process.env.CODEX_HOME || undefined });
function unavailable(message: string): never { throw new DeliveryGuardError("expected_identity_unavailable", message); }
function mismatch(message: string): never { throw new DeliveryGuardError("expected_identity_mismatch", message); }
export function expectedConversation(value: unknown): ExpectedConversation {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new DeliveryGuardError("invalid_expected_identity", "An expected conversation identity is required.");
const raw = value as Record<string, unknown>;
for (const key of ["nodeId", "sessionName", "nativeId"]) {
if (typeof raw[key] !== "string" || !(raw[key] as string).length || (raw[key] as string).length > 256 || /\s/.test(raw[key] as string))
throw new DeliveryGuardError("invalid_expected_identity", `Expected identity needs a nonempty ${key}, without whitespace.`);
}
if (raw.runtime !== "claude-code" && raw.runtime !== "codex") throw new DeliveryGuardError("invalid_expected_identity", "Expected identity supports claude-code or codex only.");
return { nodeId: raw.nodeId as string, sessionName: raw.sessionName as string, runtime: raw.runtime, nativeId: raw.nativeId as string };
}
export function sameConversation(a: ExpectedConversation, b: ExpectedConversation): boolean {
return a.nodeId === b.nodeId && a.sessionName === b.sessionName && a.runtime === b.runtime && a.nativeId === b.nativeId;
}
/** Opt-in consumer check; a registry resume token alone never authorizes the operation. */
export async function resolveExpectedConversation(expected: ExpectedConversation, registry: SessionRegistry, tmux: TmuxAdapter,
store: ContextUsageStore | undefined): Promise<{ identity: ExpectedConversation; path: string; generation: string; pane: string; assertCurrent: () => void }> {
if (!store) unavailable("No native record resolver is available; no conditional input or record is served.");
const context = registry.findResumeContextByName(expected.sessionName);
if (!context) unavailable("The expected seat has no registered conversation.");
if (context.nodeId !== expected.nodeId || context.runtime !== expected.runtime) mismatch("The seat or runtime is no longer the expected one.");
const occupant = registry.currentOccupantTenure(context.nodeId), binding = registry.getBindingForNode(context.nodeId);
const proof = new SeatIdentityStore(registry.db).getForNode(context.nodeId);
if (!occupant || !binding?.tmuxPane || binding.tmuxSession !== expected.sessionName || proof?.verdict !== "verified"
|| proof.sessionName !== expected.sessionName || proof.evidence.registeredPane !== binding.tmuxPane
|| !(Date.parse(proof.observedAt) >= parseSqliteUtcMs(occupant.bootAt))) unavailable("The current occupant/pane identity is unverified.");
const pane = binding.tmuxPane, generation = occupant.generationUuid;
const assertCurrent = () => {
const current = registry.findResumeContextByName(expected.sessionName), bound = registry.getBindingForNode(expected.nodeId);
if (current?.nodeId !== expected.nodeId || current.runtime !== expected.runtime || bound?.tmuxSession !== expected.sessionName
|| bound.tmuxPane !== pane || registry.currentOccupantTenure(expected.nodeId)?.generationUuid !== generation)
mismatch("The occupant binding changed during the conditional operation.");
};
let nativeId: string, path: string | null, rootPid: number | null;
try {
rootPid = await tmux.getPanePid(pane);
if (!rootPid || rootPid !== (proof.evidence.observedPanePid ?? proof.evidence.observedPid)) unavailable("The pane process no longer matches its verified occupant.");
const deps = { getPanePid: () => tmux.getPanePid(pane), listProcesses: () => census.list(),
readThreadIdByPid: async (pid: number, startedAt?: string) => startedAt ? threads.resolve(pid, startedAt) : undefined };
if (expected.runtime === "claude-code") {
const sidecar = store.readSidecar(expected.sessionName);
const selected = await resolveIdentityVerifiedClaudeRecord({ sessionName: expected.sessionName, generation,
occupantBootAt: occupant.bootAt, binding, identity: proof, sidecar: sidecar.ok ? sidecar.data : null }, deps,
file => { try { return statSync(file).isFile(); } catch { return false; } });
if (!selected.ok) unavailable(selected.reason);
nativeId = selected.id; path = selected.path;
} else {
let verifiedThread: string | undefined;
const selected = await resolveLiveCodexThreadId(pane, { ...deps, readThreadIdByPid: async (pid, startedAt) => {
const id = await deps.readThreadIdByPid(pid, startedAt); if (pid === proof.evidence.observedPid) verifiedThread = id; return id;
} });
if (!selected.ok) unavailable(selected.reason);
nativeId = selected.id; path = store.readCodexTranscriptPath(nativeId);
if (proof.evidence.observedPanePid != null && verifiedThread !== nativeId) unavailable("The selected thread is not owned by the verified native process.");
}
assertCurrent();
if (await tmux.getPanePid(pane) !== rootPid) mismatch("The pane process changed during native resolution.");
assertCurrent();
} catch (error) {
if (error instanceof DeliveryGuardError) throw error;
unavailable(`Native identity could not be resolved: ${error instanceof Error ? error.message : String(error)}`);
}
if (nativeId! !== expected.nativeId) mismatch("The current native conversation differs from the expected original.");
if (!path!) unavailable("The expected native record is unavailable.");
return { identity: { ...expected }, path: path!, generation, pane, assertCurrent };
}
@@ -1,3 +1,4 @@
import type { ExpectedConversation } from "./expected-conversation.js";
import type Database from "better-sqlite3";
/**
@@ -114,12 +115,36 @@ export class OutboxHandler {
* era-stamp column, so the writer degrades (omits it) instead of throwing. */
private readonly hasIdentityProvenanceColumn: boolean;
private readonly hasIdentityPrincipalColumn: boolean;
private readonly hasGuardDeliveryColumn: boolean;
constructor(db: Database.Database) {
this.db = db;
const columns = (this.db.prepare("PRAGMA table_info(outbox_entries)").all() as Array<{ name: string }>).map((col) => col.name);
this.hasIdentityProvenanceColumn = columns.includes("identity_provenance");
this.hasIdentityPrincipalColumn = columns.includes("identity_principal");
this.hasGuardDeliveryColumn = columns.includes("guard_delivery");
}
/** The optional original-conversation constraint shares the existing durable delivery receipt. */
expectedIdentity(outboxId: string): ExpectedConversation | undefined {
if (!this.hasGuardDeliveryColumn) return undefined;
const row = this.db.prepare("SELECT guard_delivery FROM outbox_entries WHERE outbox_id=?").get(outboxId) as { guard_delivery: string | null } | undefined;
return row?.guard_delivery ? JSON.parse(row.guard_delivery).expectedIdentity : undefined;
}
bindExpectedIdentity(input: OutboxRecordInput & { outboxId: string }, expected: ExpectedConversation): void {
if (!this.hasGuardDeliveryColumn) throw new OutboxHandlerError("expected_identity_unavailable", "The durable conditional-delivery schema is unavailable.");
this.db.transaction(() => {
const prior = this.getById(input.outboxId), bound = this.expectedIdentity(input.outboxId);
if (prior && (prior.senderSession !== input.senderSession || prior.destinationSession !== input.destinationSession || prior.body !== input.body)
|| bound && ["nodeId", "sessionName", "runtime", "nativeId"].some(key => bound[key as keyof ExpectedConversation] !== expected[key as keyof ExpectedConversation]))
throw new OutboxHandlerError("delivery_identity_conflict", "This delivery ID already selects different input or an original conversation.");
if (!prior) this.record(input);
const row = this.db.prepare("SELECT guard_delivery FROM outbox_entries WHERE outbox_id=?").get(input.outboxId) as { guard_delivery: string | null };
const receipt = row.guard_delivery ? JSON.parse(row.guard_delivery) : { ids: [input.outboxId] };
this.db.prepare("UPDATE outbox_entries SET guard_delivery=? WHERE outbox_id=?")
.run(JSON.stringify({ ...receipt, expectedIdentity: expected }), input.outboxId);
})();
}
record(input: OutboxRecordInput): OutboxEntry {
@@ -1,3 +1,5 @@
import { expectedConversation, sameConversation, resolveExpectedConversation, type ExpectedConversation } from "./expected-conversation.js";
import type { ContextUsageStore } from "./context-usage-store.js";
import { physicalSessionName } from "./session-registry.js";
import { currentClaudeMcpMenu } from "./claude-mcp-menu.js";
import { currentClaudeResumePicker } from "./native-resume-probe.js";
@@ -1718,6 +1720,7 @@ export function readInputBox(pane: string | null, runtime: "claude-code" | "code
}
export interface SendOpts {
expectedIdentity?: ExpectedConversation;
/** Internal provenance derived by the transport route. */
identityProvenance?: string;
/** Internal: with identityProvenance principal:v1, the MCP adapter's caller (the route's resolveRecordedIdentity). */
@@ -1857,6 +1860,8 @@ export interface ExistingInput {
type OverrideInputAudit = { classification: ExistingInput["classification"]; visibleRows: number; suggestionRows: number; choice: "inspect" | "type_without_checking"; outcome: "stopped" | "proceed"; clearAttempted: false };
export interface SendResult {
checkedIdentity?: ExpectedConversation;
identityContract?: 1;
existingInput?: ExistingInput;
ok: boolean;
sessionName: string;
@@ -1959,6 +1964,7 @@ export interface TransportReadinessChecker {
}
interface SessionTransportDeps {
contextUsageStore?: ContextUsageStore;
db: Database.Database;
rigRepo: RigRepository;
sessionRegistry: SessionRegistry;
@@ -2014,6 +2020,7 @@ interface SessionReconciliationSnapshot {
}
export class SessionTransport {
private contextUsageStore?: ContextUsageStore;
readonly db: Database.Database;
readonly guardedDelivery?: DraftAwareDelivery;
private rigRepo: RigRepository;
@@ -2034,6 +2041,7 @@ export class SessionTransport {
private verifyInputBox?: () => boolean;
constructor(deps: SessionTransportDeps) {
this.contextUsageStore = deps.contextUsageStore;
this.db = deps.db;
this.rigRepo = deps.rigRepo;
this.sessionRegistry = deps.sessionRegistry;
@@ -2426,6 +2434,57 @@ export class SessionTransport {
}
private async sendClassified(sessionName: string, text: string, opts?: SendOpts): Promise<SendResult> {
// The constraint belongs to the durable input, including later committed dispatch and ID reuse.
const outbox = new OutboxHandler(this.db);
let ids = opts?.committedOutboxIds ?? (opts?.deliveryId ? [opts.deliveryId] : []);
try {
let expected = opts?.expectedIdentity ? expectedConversation(opts.expectedIdentity) : undefined;
for (const id of ids) {
const saved = outbox.expectedIdentity(id);
if (!saved) continue;
const bound = expectedConversation(saved);
if (expected && !sameConversation(expected, bound)) throw new DeliveryGuardError("delivery_identity_conflict", "This input ID is bound to another original conversation.");
expected = bound;
}
if (!expected) return this.sendClassifiedInput(sessionName, text, opts);
if (sessionName !== expected.sessionName) throw new DeliveryGuardError("expected_identity_mismatch", "The destination must be the expected canonical session.");
if (!ids.length) ids = [`expected-send-${randomUUID()}`];
this.db.transaction(() => {
for (const id of ids) {
const prior = opts?.committedOutboxIds ? outbox.getById(id) : null;
if (opts?.committedOutboxIds && !prior) throw new DeliveryGuardError("delivery_identity_conflict", "Committed input ID is missing.");
outbox.bindExpectedIdentity(prior ? { ...prior, outboxId: id, tags: prior.tags ?? undefined, auditPointer: prior.auditPointer ?? undefined } : {
outboxId: id, senderSession: opts?.actorSession ?? "unknown", destinationSession: sessionName, body: text,
identityProvenance: opts?.identityProvenance, identityPrincipal: opts?.identityPrincipal,
}, expected!);
}
})();
await resolveExpectedConversation(expected, this.sessionRegistry, this.tmuxAdapter, this.contextUsageStore);
const result = await this.sendClassifiedInput(sessionName, text, { ...opts, expectedIdentity: expected,
...(opts?.committedOutboxIds ? {} : { deliveryId: ids[0] }) });
if (result.outcome !== "retained") {
try {
for (const id of ids) {
if (result.ok) outbox.markDelivered(id);
else if (result.sent === false || result.inputPhase === "not_reached") outbox.markFailed(id);
else outbox.markIndeterminate(id);
}
} catch { result.warning = [result.warning, "Conditional input receipt could not be finalized; inspect before retrying."].filter(Boolean).join(" "); }
}
return { ...result, identityContract: 1, outboxIds: result.outboxIds ?? ids };
} catch (error) {
// Before-input conditional failures never fall back to an ordinary send.
const written = (error as { inputWritten?: boolean }).inputWritten;
const attempted = (error as { inputAttempted?: boolean }).inputAttempted;
if (!written && !attempted) for (const id of ids) { try { if (outbox.getById(id)) outbox.markFailed(id); } catch { /* Original refusal remains authoritative. */ } }
return { ok: false, sessionName, identityContract: 1, outboxIds: ids,
sent: written ? true : attempted ? undefined : false,
inputPhase: written ? "written" : attempted ? "attempted" : "not_reached",
reason: (error as { code?: string }).code ?? "expected_identity_unavailable", error: (error as Error).message };
}
}
private async sendClassifiedInput(sessionName: string, text: string, opts?: SendOpts): Promise<SendResult> {
const guard = this.tmuxAdapter.deliveryGuard;
const admission: AdmissionInput = { source: opts?.openrigNotice ? "openrig" : "agent", urgent: !!opts?.urgent, override: opts?.dangerouslyInteract === true };
if (!guard) {
@@ -2497,7 +2556,7 @@ export class SessionTransport {
if (this.guardedDelivery && !memberOpts?.submitOnly && !memberOpts?.dangerouslyInteract && guard.needsInputCheck(sessionName)) {
if (callerSignal(memberOpts)?.aborted) return callerCancelled(sessionName);
return this.guardedDelivery.send(sessionName, memberText, memberOpts ?? {},
memberOpts?.committedOutboxIds ?? [`guard-send-${randomUUID()}`]);
memberOpts?.committedOutboxIds ?? [memberOpts?.deliveryId ?? `guard-send-${randomUUID()}`]);
}
return this.sendUnguarded(sessionName, memberText, memberOpts);
}, async target => {
@@ -2701,6 +2760,13 @@ export class SessionTransport {
sessionName = physicalSessionName(this.db, sessionName);
let preVerifyContent: string | null = null;
const sessionMeta = this.getSessionMeta(sessionName);
let checkedIdentity: ExpectedConversation | undefined;
let originalCheck: Awaited<ReturnType<typeof resolveExpectedConversation>> | undefined;
const checkOriginal = opts?.expectedIdentity ? async () => {
originalCheck = await resolveExpectedConversation(opts.expectedIdentity!, this.sessionRegistry, this.tmuxAdapter, this.contextUsageStore);
checkedIdentity = originalCheck.identity;
} : undefined;
const beforeOriginalInput = () => { originalCheck?.assertCurrent(); opts?.beforeWrite?.(); };
const runtime = sessionMeta.runtime;
const readTarget = sessionMeta.pane ?? sessionName;
let runtimeAdvisory: string | undefined;
@@ -2749,6 +2815,7 @@ export class SessionTransport {
let existingInput: ExistingInput | undefined;
let overrideBeforePaste: (() => Promise<void>) | undefined;
const observe = (result: SendResult): SendResult => {
if (checkedIdentity) result = { ...result, checkedIdentity, identityContract: 1 };
if (existingInput) result = { ...result, existingInput };
if (result.ok && runtimeAdvisory) result = { ...result, warning: [runtimeAdvisory, result.warning].filter(Boolean).join(" ") };
if (observed && this.captureObserver) {
@@ -2903,7 +2970,8 @@ export class SessionTransport {
try {
submitResult = await this.runStage(
"session_transport.submit",
() => beforeKey ? this.tmuxAdapter.sendKeys(sessionName, ["Enter"], beforeKey) : this.tmuxAdapter.sendKeys(sessionName, ["Enter"]),
() => checkOriginal ? this.tmuxAdapter.sendKeys(sessionName, ["Enter"], () => { beforeOriginalInput(); beforeKey?.(); }, { beforeKeys: checkOriginal })
: beforeKey ? this.tmuxAdapter.sendKeys(sessionName, ["Enter"], beforeKey) : this.tmuxAdapter.sendKeys(sessionName, ["Enter"]),
(result) => result.ok ? "ok" : "failed",
);
} catch (error) {
@@ -2913,7 +2981,7 @@ export class SessionTransport {
}
if (!submitResult.ok && submitResult.code === CALLER_CANCELLED && cancel?.aborted) return callerCancelled(sessionName);
if (!submitResult.ok) {
return { ok: false, sessionName, reason: "submit_failed", outcome: "failed", error: `submitOnly: Enter did not land on '${sessionName}': ${submitResult.message}` };
return { ok: false, sessionName, ...(submitResult.code?.startsWith("expected_identity_") ? { sent: false, inputPhase: "not_reached" as const } : {}), reason: submitResult.code?.startsWith("expected_identity_") ? submitResult.code : "submit_failed", outcome: "failed", error: `submitOnly: Enter did not land on '${sessionName}': ${submitResult.message}` };
}
try { opts.onInputEffect?.("submitted"); } catch { /* Observation cannot change an input result. */ }
return observe({ ok: true, sessionName, outcome: "rendered-unconfirmed", submitOnly: true });
@@ -3217,6 +3285,9 @@ export class SessionTransport {
() => {
opts?.beforeWrite?.();
writeIssued = true;
if (checkOriginal) return this.tmuxAdapter.sendText(sessionName, text, () => { beforeOriginalInput(); beforePaste?.(); }, {
...(promptOverride ? { bracketed: false } : {}), beforePaste: async () => { await overrideBeforePaste?.(); await checkOriginal(); },
});
if (promptOverride) return this.tmuxAdapter.sendText(sessionName, text, beforePaste, { bracketed: false, beforePaste: overrideBeforePaste });
return beforePaste ? this.tmuxAdapter.sendText(sessionName, text, beforePaste) : this.tmuxAdapter.sendText(sessionName, text);
},
@@ -3227,7 +3298,7 @@ export class SessionTransport {
}
if (!textResult.ok) {
// A draft refusal is the guard holding the write before any input; it is not an attempted write.
const policyRefusal = typeof textResult.code === "string" && (textResult.code.startsWith("draft_") || textResult.code.startsWith("override_input_") || textResult.code === "managed_compaction_pending");
const policyRefusal = typeof textResult.code === "string" && (textResult.code.startsWith("expected_identity_") || textResult.code.startsWith("draft_") || textResult.code.startsWith("override_input_") || textResult.code === "managed_compaction_pending");
return observe({
ok: false,
sessionName,
@@ -3279,11 +3350,15 @@ export class SessionTransport {
// 5. Submit (Enter)
submitResult = await this.runStage(
"session_transport.submit",
() => { opts?.beforeWrite?.(); enterIssued = true; return opts?.beforeWrite ? this.tmuxAdapter.sendKeys(sessionName, ["Enter"], opts.beforeWrite) : this.tmuxAdapter.sendKeys(sessionName, ["Enter"]); },
() => {
if (checkOriginal) return this.tmuxAdapter.sendKeys(sessionName, ["Enter"], () => { beforeOriginalInput(); enterIssued = true; }, { beforeKeys: checkOriginal });
opts?.beforeWrite?.(); enterIssued = true;
return opts?.beforeWrite ? this.tmuxAdapter.sendKeys(sessionName, ["Enter"], opts.beforeWrite) : this.tmuxAdapter.sendKeys(sessionName, ["Enter"]);
},
(result) => result.ok ? "ok" : "failed",
);
if (!submitResult.ok) {
const policyRefusal = typeof submitResult.code === "string" && (submitResult.code.startsWith("draft_") || submitResult.code === "managed_compaction_pending");
const policyRefusal = typeof submitResult.code === "string" && (submitResult.code.startsWith("expected_identity_") || submitResult.code.startsWith("draft_") || submitResult.code === "managed_compaction_pending");
return observe({
ok: false,
sessionName,
+38 -11
View File
@@ -1,3 +1,5 @@
import type { Context } from "hono";
import { expectedConversation, resolveExpectedConversation, type ExpectedConversation } from "../domain/expected-conversation.js";
import { unknownModelObservation, type CurrentModelReader } from "../domain/model-divergence/current-model-reader.js";
import { inventoryCaptureOptions, type ShadowCapture } from "../domain/shadow-capture.js";
import { Hono } from "hono";
@@ -731,11 +733,17 @@ sessionAdminRoutes.post("/:sessionRef/unclaim", terminalAuthGuard(), async (c) =
// impossible, never receive an empty success.
// Round-2 (r2 HIGH-2): raw conversation bytes with no transcript redaction — a TERMINAL-CLASS
// surface behind the same bearer gate as its neighbors (401/401/200; null-token loopback passes).
sessionAdminRoutes.get("/:sessionName/generation-record", terminalAuthGuard(), async (c) => {
const generationRecord = async (c: Context) => {
const sessionName = c.req.param("sessionName")!;
let expected: ExpectedConversation | undefined;
if (c.req.path.endsWith("/generation-record-expected")) {
try { expected = expectedConversation({ nodeId: c.req.query("expectedNodeId"), sessionName,
runtime: c.req.query("expectedRuntime"), nativeId: c.req.query("expectedNativeId") }); }
catch (error) { return c.json({ error: "invalid_expected_identity", message: (error as Error).message }, 400); }
}
const store = c.get("contextUsageStore" as never) as ContextUsageStore | undefined;
if (!store) {
return c.json({ error: "unsupported_runtime", message: "No context-usage store on this daemon; the seat's generation record cannot be resolved." }, 409);
return c.json({ error: expected ? "expected_identity_unavailable" : "unsupported_runtime", message: "No context-usage store on this daemon; the seat's generation record cannot be resolved." }, 409);
}
const { sessionRegistry: registry, tmuxAdapter } = getDeps(c);
const context = registry?.findResumeContextByName(sessionName);
@@ -743,7 +751,12 @@ sessionAdminRoutes.get("/:sessionName/generation-record", terminalAuthGuard(), a
const runtime = context?.runtime ?? "claude-code";
let transcriptPath: string | null;
let sessionId: string | null;
if (runtime === "codex") {
let original: Awaited<ReturnType<typeof resolveExpectedConversation>> | undefined;
if (expected) {
try { original = await resolveExpectedConversation(expected, registry, tmuxAdapter, store);
sessionId = original.identity.nativeId; transcriptPath = original.path;
} catch (error) { return c.json({ error: (error as { code?: string }).code ?? "expected_identity_unavailable", message: (error as Error).message }, 409); }
} else if (runtime === "codex") {
const binding = registry.getBindingForNode(context!.nodeId);
const identity = new SeatIdentityStore(registry.db).getForNode(context!.nodeId);
if (!occupant || !binding?.tmuxPane || binding.tmuxSession !== sessionName
@@ -802,15 +815,16 @@ sessionAdminRoutes.get("/:sessionName/generation-record", terminalAuthGuard(), a
// Cap the served suffix — the caller polls; a runaway record must not become a runaway response.
const MAX_SUFFIX_BYTES = 8 * 1024 * 1024;
try {
const fd = fs.openSync(transcriptPath, "r");
if (expected) { original = await resolveExpectedConversation(expected, registry, tmuxAdapter, store); transcriptPath = original.path; }
const fd = fs.openSync(original?.path ?? transcriptPath, "r");
try {
const stat = fs.fstatSync(fd);
if (!stat.isFile()) throw new Error("record is not a regular file");
const totalBytes = stat.size;
// Identity and bytes come from the same open file. Appends preserve this identity;
// replacing a rollout at the same path changes it, even if its native id is unchanged.
const generationId = JSON.stringify([occupant?.generationUuid ?? null, sessionId, transcriptPath, stat.dev, stat.ino, stat.birthtimeMs]);
if (runtime === "codex") {
const generationId = JSON.stringify([original?.generation ?? occupant?.generationUuid ?? null, sessionId, transcriptPath, stat.dev, stat.ino, stat.birthtimeMs]);
if (runtime === "codex" || expected) {
let header = "";
for (let offset = 0; offset < Math.min(totalBytes, MAX_SUFFIX_BYTES) && !header.includes("\n");) {
const chunk = Buffer.alloc(Math.min(64 * 1024, totalBytes - offset, MAX_SUFFIX_BYTES - offset));
@@ -821,20 +835,33 @@ sessionAdminRoutes.get("/:sessionName/generation-record", terminalAuthGuard(), a
}
let meta;
try { meta = JSON.parse(header.slice(0, header.indexOf("\n"))); } catch { /* explicit no-answer below */ }
if (meta?.type !== "session_meta" || meta.payload?.id !== sessionId) {
if (runtime === "claude-code" && expected) {
// Claude may prefix its JSONL with snapshots; identity must be present in the opened record.
const head = Buffer.alloc(Math.min(totalBytes, MAX_SUFFIX_BYTES));
const n = fs.readSync(fd, head, 0, head.length, 0);
const ids = new Set<string>();
for (const line of head.subarray(0, n).toString("utf8").split("\n")) {
try { const row = JSON.parse(line); if (typeof row.sessionId === "string") ids.add(row.sessionId); } catch { /* incomplete last line */ }
}
if (ids.size !== 1 || !ids.has(sessionId!)) return c.json({ error: "expected_identity_mismatch", message: "The opened record does not identify only the expected original conversation." }, 409);
} else if (meta?.type !== "session_meta" || meta.payload?.id !== sessionId) {
return c.json({ error: "record_identity_mismatch", message: `The rollout header does not identify current thread '${sessionId}'.` }, 409);
}
}
if (sinceRaw === undefined) return c.json({ generationId, sessionId, runtime, totalBytes });
original?.assertCurrent();
const receipt = expected ? { checkedIdentity: expected, identityContract: 1 as const } : {};
if (sinceRaw === undefined) return c.json({ generationId, sessionId, runtime, totalBytes, ...receipt });
if (since > totalBytes) return c.json({ error: "record_truncated", message: "Generation record shrank below the requested byte boundary." }, 409);
const length = Math.min(totalBytes - since, MAX_SUFFIX_BYTES);
const buf = Buffer.alloc(length);
const read = fs.readSync(fd, buf, 0, length, since);
return c.json({ generationId, sessionId, runtime, totalBytes, suffix: buf.subarray(0, read).toString("utf8"), truncated: totalBytes - since > read });
return c.json({ generationId, sessionId, runtime, totalBytes, ...receipt, suffix: buf.subarray(0, read).toString("utf8"), truncated: totalBytes - since > read });
} finally {
fs.closeSync(fd);
}
} catch (err) {
return c.json({ error: "record_unreadable", message: `Generation record for '${sessionName}' is unreadable: ${(err as Error).message}` }, 409);
return c.json({ error: expected ? (err as { code?: string }).code ?? "expected_identity_unavailable" : "record_unreadable", message: `Generation record for '${sessionName}' is unreadable: ${(err as Error).message}` }, 409);
}
});
};
sessionAdminRoutes.get("/:sessionName/generation-record", terminalAuthGuard(), generationRecord);
sessionAdminRoutes.get("/:sessionName/generation-record-expected", terminalAuthGuard(), generationRecord);
+18 -3
View File
@@ -1,4 +1,5 @@
import { Hono } from "hono";
import { expectedConversation, type ExpectedConversation } from "../domain/expected-conversation.js";
import { Hono, type Context } from "hono";
import { CAPTURE_INPUT_BOX_DEADLINE_MS, CAPTURE_READABLE_BUDGET_MS, type SessionTransport, type TargetSpec } from "../domain/session-transport.js";
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
import { requireSenderIdentity, resolveRecordedIdentity, transportSenderSession } from "./require-sender-identity.js";
@@ -28,11 +29,12 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
const terminalToken = opts?.bearerToken ?? null;
router.use("*", authBearerTokenMiddleware({ expectedToken: terminalToken }));
router.post("/send", async (c) => {
const send = async (c: Context) => {
const transport = c.get("sessionTransport" as never) as SessionTransport;
const body = await c.req.json<{
session?: string;
deliveryId?: string;
expectedIdentity?: unknown;
text: string;
verify?: boolean;
force?: boolean;
@@ -49,6 +51,12 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
urgent?: boolean;
}>();
let expectedIdentity: ExpectedConversation | undefined;
if (c.req.path.endsWith("/send-expected") || body.expectedIdentity !== undefined) {
try { expectedIdentity = expectedConversation(body.expectedIdentity); }
catch (error) { return c.json({ ok: false, sent: false, reason: "invalid_expected_identity", error: (error as Error).message }, 400); }
}
// submitOnly (mechanics-gate fix d9b3989a) sends NO text — the Enter-only retry for staged
// content; every other send still requires text.
if (!body.session || (!body.text && !body.submitOnly)) {
@@ -130,6 +138,7 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
const result = await transport.send(body.session, body.text ?? "", {
deliveryId: body.deliveryId,
expectedIdentity,
verify: body.verify,
force: body.force,
waitForIdleMs: body.waitForIdleMs,
@@ -158,6 +167,9 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
if (!result.ok) {
const statusMap: Record<string, number> = {
expected_identity_mismatch: 409,
expected_identity_unavailable: 409,
invalid_expected_identity: 400,
typing_guard_enabled: 409,
caller_cancelled: 409,
guard_target_unknown: 409,
@@ -239,7 +251,10 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
}
return c.json(result);
});
};
router.post("/send", send);
// Separate capability path: an older daemon refuses with 404 instead of ignoring a new field.
router.post("/send-expected", send);
router.post("/capture", async (c) => {
const transport = c.get("sessionTransport" as never) as SessionTransport;
+1
View File
@@ -1523,6 +1523,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
transcriptStore,
sessionTransport: (() => {
const t = new SessionTransport({
contextUsageStore,
db,
rigRepo,
sessionRegistry,