Files
orca/src/shared/agent-session-pty-write-admission.ts
T
Brennan Benson c69b12b3dd Enforce the agent-session lease at every PTY write choke point
Route every byte that can reach a PTY through the single-writer lease
adjudicator, so a session owned by one runtime cannot be typed into by
another. Nothing binds a PTY to a durable session record yet, so the gate
short-circuits and today's shells, legacy agent terminals, and unbound
sessions are untouched.

Gated paths: terminal.send RPC, the renderer IPC write paths
(pty:write / pty:writeAccepted), plugin sendTerminalText, the agent-prompt
paste, and ptyController.write as a backstop for runtime writes that never
passed a typed gate. Every yield inside a write - inter-chunk, the
text/suffix pause, the agent-prompt submit delay, deferred size
measurement - re-checks the fence, so a handoff mid-paste stops the
remaining chunks instead of interleaving two owners.

Refusals are typed and additive on the wire: an optional
agentSessionRefusal on RuntimeTerminalSend and on the existing
pty:writeUnavailable payload, refusal codes drawn only from the existing
agent-session error codes, and stream-input refusals mapped onto the
WriteUnavailable frame old clients already decode.
2026-08-09 13:00:56 -07:00

178 lines
6.5 KiB
TypeScript

/**
* PTY-write admission for agent sessions that have a durable record.
*
* A PTY is the TUI runtime's write surface, so bytes may enter it only while the session's lease
* admits a writer and that writer is the TUI. The admit decision is `agentSessionLeaseAdmitsWriter`
* verbatim; everything here only decides whether the lease is even the TUI's to hold, and — once
* that helper has already refused — which refusal a client should be shown.
*
* A PTY with no binding is a session this host knows nothing about: every ordinary shell and every
* legacy agent terminal. Those are never consulted and never refused. A binding whose record is
* missing is the opposite case — the record was lost, not absent — and fails closed.
*/
import {
agentSessionLeaseAdmitsWriter,
isAgentSessionFenceCurrent
} from './agent-session-lease-adjudication'
import type {
AgentSessionHandoffStage,
AgentSessionLease,
AgentSessionOwnerRuntimeKind,
AgentSessionRecord
} from './agent-session-record'
/**
* Every code is already in `AGENT_SESSION_RPC_ERROR_CODES`, so a refusal reaching an old client
* carries a code it has seen since the host-authority release rather than a new one.
*/
export type AgentSessionPtyWriteRefusalCode =
| 'agent_session_conflict'
| 'agent_session_ownership_unknown'
| 'agent_session_checkpoint_stale'
| 'execution_owner_reconciling'
export type AgentSessionPtyWriteRefusal = {
code: AgentSessionPtyWriteRefusalCode
sessionId: string
/** Runtime the lease names as owner; null once the record is gone. */
ownerRuntimeKind: AgentSessionOwnerRuntimeKind | null
handoffStage: AgentSessionHandoffStage | null
/** Pid the lease names, so the refusal can say who holds the session. */
ownerPid: number | null
runtimeFence: number | null
}
export type AgentSessionPtyWriteAdmission =
| { admitted: true; sessionId: string | null; runtimeFence: number | null }
| { admitted: false; refusal: AgentSessionPtyWriteRefusal }
/** One PTY's durable binding: the session it belongs to and that session's record, if readable. */
export type AgentSessionPtyBinding = {
sessionId: string
record: AgentSessionRecord | null
}
const UNBOUND_ADMISSION: AgentSessionPtyWriteAdmission = {
admitted: true,
sessionId: null,
runtimeFence: null
}
/** Runs only after `agentSessionLeaseAdmitsWriter` (or the runtime-kind test) already refused. */
function classifyRefusal(lease: AgentSessionLease): AgentSessionPtyWriteRefusalCode {
if (lease.unreconciled) {
return 'execution_owner_reconciling'
}
if (lease.claimStatus === 'conflicted') {
return 'agent_session_conflict'
}
if (lease.handoffStage === 'recovering' || lease.handoffStage === 'manual-recovery') {
return 'execution_owner_reconciling'
}
if (lease.handoffStage !== null || lease.runtimeKind !== 'tui') {
// Why: a live native owner and a mid-flight handoff are both "someone else holds it", which is
// actionable in a way "we cannot tell" is not.
return 'agent_session_conflict'
}
return 'agent_session_ownership_unknown'
}
function refuse(
code: AgentSessionPtyWriteRefusalCode,
sessionId: string,
lease: AgentSessionLease | null
): AgentSessionPtyWriteAdmission {
return {
admitted: false,
refusal: {
code,
sessionId,
ownerRuntimeKind: lease?.runtimeKind ?? null,
handoffStage: lease?.handoffStage ?? null,
ownerPid: lease?.ownerProcess?.pid ?? null,
runtimeFence: lease?.runtimeFence ?? null
}
}
}
export function evaluateAgentSessionPtyWriteAdmission(
binding: AgentSessionPtyBinding | null
): AgentSessionPtyWriteAdmission {
if (!binding) {
return UNBOUND_ADMISSION
}
const record = binding.record
if (!record) {
// Why: a bound PTY whose record cannot be read is a lost lease, not an unmanaged shell.
return refuse('execution_owner_reconciling', binding.sessionId, null)
}
if (record.sessionId !== binding.sessionId) {
return refuse('agent_session_ownership_unknown', binding.sessionId, record.lease)
}
const lease = record.lease
if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) {
return { admitted: true, sessionId: record.sessionId, runtimeFence: lease.runtimeFence }
}
return refuse(classifyRefusal(lease), binding.sessionId, lease)
}
/**
* Re-admit a write that already began. Chunked input and the text/suffix pause both yield, so a
* lease transition can land between two writes of one logical send; the fence observed at
* admission is what the remaining bytes are checked against.
*/
export function reevaluateAgentSessionPtyWriteAdmission(args: {
admitted: { sessionId: string | null; runtimeFence: number | null }
binding: AgentSessionPtyBinding | null
}): AgentSessionPtyWriteAdmission {
const { admitted, binding } = args
const next = evaluateAgentSessionPtyWriteAdmission(binding)
if (admitted.sessionId === null || admitted.runtimeFence === null) {
// Why: an unbound write that acquires a binding mid-flight is judged on the new binding alone.
return next
}
if (!next.admitted) {
return next
}
const lease = binding?.record?.lease ?? null
if (
next.sessionId !== admitted.sessionId ||
!lease ||
!isAgentSessionFenceCurrent(lease, admitted.runtimeFence)
) {
return refuse('agent_session_checkpoint_stale', admitted.sessionId, lease)
}
return next
}
export class AgentSessionPtyWriteRefusedError extends Error {
readonly refusal: AgentSessionPtyWriteRefusal
constructor(refusal: AgentSessionPtyWriteRefusal) {
// Why: callers that already switch on `error.message` as an RPC code keep working unchanged.
super(refusal.code)
this.name = 'AgentSessionPtyWriteRefusedError'
this.refusal = refusal
}
}
export function isAgentSessionPtyWriteRefusedError(
error: unknown
): error is AgentSessionPtyWriteRefusedError {
return error instanceof AgentSessionPtyWriteRefusedError
}
/** Human-readable refusal, so a client that only surfaces a message still names the owner. */
export function describeAgentSessionPtyWriteRefusal(refusal: AgentSessionPtyWriteRefusal): string {
const owner =
refusal.ownerRuntimeKind === null
? 'no recorded owner'
: `${refusal.ownerRuntimeKind === 'native' ? 'native chat' : 'the agent TUI'}${
refusal.ownerPid === null ? '' : ` (pid ${refusal.ownerPid})`
}`
const stage =
refusal.handoffStage === null ? 'no handoff in progress' : `handoff ${refusal.handoffStage}`
return `Agent session ${refusal.sessionId} is held by ${owner}; ${stage} (${refusal.code}).`
}