mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
* fix(relay): one malformed pre-auth handshake frame closes its connection, not the daemon
parseHandshakeMessage returned whatever JSON.parse produced, and the daemon
interpolates the peer's version into a log line before any credential check.
A version that is an object with a non-callable toString throws TypeError
there, inside the frame-decoder callback. FrameDecoder.drainTurn wrapped its
synchronous dispatch in try/finally with no catch, so the throw escaped
feed(), escaped the socket data handler, and reached uncaughtException: the
relay daemon exited and every PTY and agent session it held died with it.
Two layers, because only the second closes the class:
- parseHandshakeMessage now requires the string fields each arm carries
(version; expected/got) and rejects a non-object payload. Both readers
share the parser, so neither side can interpolate a non-string again.
- FrameDecoder contains a frame owner that throws on the synchronous turn
the same way it already contained one on a continuation turn: reset the
residue and report one FrameDecoderContinuationError to onError. Every
owner's onError already closes its own connection, so any future throw
of this shape costs one connection instead of the process.
The relay CLI channel gains an explicit onError so a malformed reply still
ends that one-shot command instead of parking it.
* fix(relay): keep the diagnostic the refusal path exists to produce
Two error paths that destroy their own evidence.
`parseHandshakeMessage`'s unknown-type refusal interpolated `String(t)` on a
peer-supplied value: `{"type":{"toString":1}}` makes String() throw "Cannot
convert object to primitive value", so the refusal arrives without naming what
was refused. `describeRelayProtocolVersion` guards this exact hazard two files
away; the sibling was missed.
`runRelayOrcaCliChannel`'s new `onDecodeError` wrote to stderr and then exited
synchronously. stderr is async on a pipe transport, so the one line recording
why the command died could be dropped — the reason relay-handshake.ts already
exits inside its write callback.
* fix(relay): prove the optional handshake field too, not just the required ones
The parser refuses a non-string `version`, `expected` and `got`, then returns the
object with `endpointCredential` unproved — the most pre-auth field on the frame.
It is safe today only by accident: its one reader compares it, and a non-string
loses that comparison. Nothing holds that shape in place, and the next reader to
put it in a log line reinstates the template-literal throw this function exists
to stop.
Present-but-not-a-string is now refused at the parser. Absent stays absent: a
bridge presenting no credential is the common case, and refusing it would close
every unauthenticated-endpoint connection.
Wire-visible delta, deliberate: a peer sending a non-string credential used to get
`orca-relay-handshake-credential-mismatch` and exit 43; it now gets a bare close.
No first-party client can reach it — `runConnectHandshake` types the parameter
`string` and omits it when falsy — and a bare close is the right answer to a frame
that was malformed before any credential was checked.
* fix(relay): carry the SAFETY: rationale main's casting gate now requires
Main gained a `typescript/consistent-type-assertions` scan while this branch sat 432
commits behind, so every `as` the branch touches lands as a new finding. The parser is
the one place the handshake shape is proved, so each cast names the check that earns it,
and the hostile-frame cast in the round-trip test names the fact that it is a deliberate
lie the type system cannot describe.
* test(relay): annotate the hostile handshake frame instead of suppressing a cast
JSON.parse answers `any`, so a typed const expresses the same deliberate lie the
assertion did and the casting gate has nothing to flag. One fewer suppression.
275 lines
8.2 KiB
TypeScript
275 lines
8.2 KiB
TypeScript
import {
|
|
containFrameDecoderContinuation,
|
|
publishFrameDecoderError,
|
|
type DecodedFrame,
|
|
type FrameDecoderOptions
|
|
} from './relay-frame-decoder-contract'
|
|
import { RelayFrameBuffer } from './relay-frame-buffer'
|
|
export {
|
|
FrameDecoderContinuationError,
|
|
type DecodedFrame,
|
|
type FrameDecoderOptions
|
|
} from './relay-frame-decoder-contract'
|
|
|
|
export const HEADER_LENGTH = 13
|
|
export const MAX_MESSAGE_SIZE = 16 * 1024 * 1024
|
|
export const FRAME_DECODER_MAX_FRAMES_PER_TURN = 64
|
|
export const FRAME_DECODER_MAX_BYTES_PER_TURN = MAX_MESSAGE_SIZE + HEADER_LENGTH
|
|
export const FRAME_DECODER_MAX_TURN_MS = 4,
|
|
FRAME_DECODER_MAX_RETAINED_BYTES = MAX_MESSAGE_SIZE + HEADER_LENGTH + 1024 * 1024
|
|
|
|
export class FrameDecoder {
|
|
private readonly buffer = new RelayFrameBuffer()
|
|
private oversizedPayloadBytesRemaining = 0
|
|
private onFrame: (frame: DecodedFrame) => void
|
|
private onError: ((err: Error) => void) | null
|
|
private maxFramesPerTurn: number
|
|
private maxBytesPerTurn: number
|
|
private maxTurnMs: number
|
|
private now: () => number
|
|
private schedule: (callback: () => void) => unknown
|
|
private cancelScheduled: (handle: unknown) => void
|
|
private pause: (() => void) | null
|
|
private resume: (() => void) | null
|
|
private continuationHandle: unknown
|
|
private continuationHandleAssigned = false
|
|
private continuationScheduled = false
|
|
private paused = false
|
|
private draining = false
|
|
private generation = 0
|
|
|
|
constructor(
|
|
onFrame: (frame: DecodedFrame) => void,
|
|
onError?: (err: Error) => void,
|
|
options: FrameDecoderOptions = {}
|
|
) {
|
|
this.onFrame = onFrame
|
|
this.onError = onError ?? null
|
|
this.maxFramesPerTurn = positiveLimit(
|
|
options.maxFramesPerTurn,
|
|
FRAME_DECODER_MAX_FRAMES_PER_TURN
|
|
)
|
|
this.maxBytesPerTurn = positiveLimit(options.maxBytesPerTurn, FRAME_DECODER_MAX_BYTES_PER_TURN)
|
|
this.maxTurnMs = positiveLimit(options.maxTurnMs, FRAME_DECODER_MAX_TURN_MS)
|
|
this.now = options.now ?? Date.now
|
|
this.schedule = options.schedule ?? ((callback) => setImmediate(callback))
|
|
this.cancelScheduled =
|
|
options.cancelScheduled ?? ((handle) => clearImmediate(handle as NodeJS.Immediate))
|
|
this.pause = options.pause ?? null
|
|
this.resume = options.resume ?? null
|
|
}
|
|
|
|
feed(chunk: Buffer | Uint8Array): void {
|
|
const buf = Buffer.isBuffer(chunk)
|
|
? chunk
|
|
: Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
|
|
const retained = this.buffer.length + buf.length
|
|
if (retained > FRAME_DECODER_MAX_RETAINED_BYTES) {
|
|
this.reset()
|
|
publishFrameDecoderError(
|
|
this.onError,
|
|
new Error(`Frame decoder retained-input limit exceeded: ${retained}`)
|
|
)
|
|
return
|
|
}
|
|
if (buf.length > 0) {
|
|
this.buffer.append(buf)
|
|
}
|
|
if (!this.draining && !this.continuationScheduled) {
|
|
this.drainTurn()
|
|
}
|
|
}
|
|
|
|
reset(): void {
|
|
this.generation += 1
|
|
this.cancelContinuation()
|
|
this.buffer.clear()
|
|
this.oversizedPayloadBytesRemaining = 0
|
|
this.releasePause()
|
|
}
|
|
|
|
drain(): Buffer {
|
|
const out = this.buffer.drain()
|
|
this.reset()
|
|
return out
|
|
}
|
|
|
|
private drainTurn(): void {
|
|
if (this.draining) {
|
|
return
|
|
}
|
|
this.draining = true
|
|
const generation = this.generation
|
|
const startedAt = this.now()
|
|
let frames = 0
|
|
let bytes = 0
|
|
|
|
try {
|
|
while (generation === this.generation) {
|
|
if (
|
|
frames >= this.maxFramesPerTurn ||
|
|
bytes >= this.maxBytesPerTurn ||
|
|
(frames > 0 && this.now() - startedAt >= this.maxTurnMs)
|
|
) {
|
|
break
|
|
}
|
|
const discarded = this.discardOversizedPayload(bytes)
|
|
if (discarded > 0) {
|
|
bytes += discarded
|
|
continue
|
|
}
|
|
if (this.buffer.length < HEADER_LENGTH) {
|
|
break
|
|
}
|
|
const header = this.buffer.peek(HEADER_LENGTH)
|
|
const length = header.readUInt32BE(9)
|
|
if (length > MAX_MESSAGE_SIZE) {
|
|
this.buffer.discard(HEADER_LENGTH)
|
|
this.oversizedPayloadBytesRemaining = length
|
|
bytes += HEADER_LENGTH
|
|
publishFrameDecoderError(
|
|
this.onError,
|
|
new Error(`Frame payload too large: ${length} bytes — discarded`)
|
|
)
|
|
continue
|
|
}
|
|
const totalLength = HEADER_LENGTH + length
|
|
if (this.buffer.length < totalLength) {
|
|
break
|
|
}
|
|
if (frames > 0 && bytes + totalLength > this.maxBytesPerTurn) {
|
|
break
|
|
}
|
|
const framed = this.buffer.take(totalLength)
|
|
frames += 1
|
|
bytes += totalLength
|
|
// Why contain here and not in the caller: feed() runs straight from a socket 'data'
|
|
// handler, so a frame owner that throws on the first turn would escape as an
|
|
// uncaughtException and take the whole process — and every connection it serves — down.
|
|
// The continuation path already contains this; the synchronous path must match it, so
|
|
// one bad frame costs one connection (the owner's onError closes it), never the process.
|
|
try {
|
|
this.onFrame({
|
|
type: framed[0],
|
|
id: framed.readUInt32BE(1),
|
|
ack: framed.readUInt32BE(5),
|
|
payload: framed.subarray(HEADER_LENGTH, totalLength)
|
|
})
|
|
} catch (error) {
|
|
// reset() bumps the generation, which ends this turn and drops the residue.
|
|
containFrameDecoderContinuation(() => this.reset(), this.onError, error)
|
|
}
|
|
}
|
|
} finally {
|
|
this.draining = false
|
|
}
|
|
|
|
if (generation !== this.generation) {
|
|
return
|
|
}
|
|
if (this.hasRunnableWork()) {
|
|
this.scheduleContinuation()
|
|
} else {
|
|
this.releasePause()
|
|
}
|
|
}
|
|
|
|
private discardOversizedPayload(bytes: number): number {
|
|
if (this.oversizedPayloadBytesRemaining === 0 || this.buffer.length === 0) {
|
|
return 0
|
|
}
|
|
const discarded = Math.min(
|
|
this.oversizedPayloadBytesRemaining,
|
|
this.buffer.length,
|
|
Math.max(1, this.maxBytesPerTurn - bytes)
|
|
)
|
|
this.buffer.discard(discarded)
|
|
this.oversizedPayloadBytesRemaining -= discarded
|
|
return discarded
|
|
}
|
|
|
|
private hasRunnableWork(): boolean {
|
|
if (this.oversizedPayloadBytesRemaining > 0) {
|
|
return this.buffer.length > 0
|
|
}
|
|
if (this.buffer.length < HEADER_LENGTH) {
|
|
return false
|
|
}
|
|
const length = this.buffer.peek(HEADER_LENGTH).readUInt32BE(9)
|
|
return length > MAX_MESSAGE_SIZE || this.buffer.length >= HEADER_LENGTH + length
|
|
}
|
|
|
|
private scheduleContinuation(): void {
|
|
if (this.continuationScheduled) {
|
|
return
|
|
}
|
|
const generation = this.generation
|
|
this.continuationScheduled = true
|
|
try {
|
|
this.acquirePause()
|
|
} catch (error) {
|
|
this.continuationScheduled = false
|
|
throw error
|
|
}
|
|
if (generation !== this.generation) {
|
|
this.continuationScheduled = false
|
|
return
|
|
}
|
|
try {
|
|
this.continuationHandle = this.schedule(() => {
|
|
if (!this.continuationScheduled || generation !== this.generation) {
|
|
return
|
|
}
|
|
this.continuationScheduled = false
|
|
this.continuationHandleAssigned = false
|
|
this.continuationHandle = undefined
|
|
try {
|
|
this.drainTurn()
|
|
} catch (error) {
|
|
containFrameDecoderContinuation(() => this.reset(), this.onError, error)
|
|
}
|
|
})
|
|
this.continuationHandleAssigned = true
|
|
} catch (error) {
|
|
this.continuationScheduled = false
|
|
this.continuationHandle = undefined
|
|
this.releasePause()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private cancelContinuation(): void {
|
|
if (!this.continuationScheduled) {
|
|
return
|
|
}
|
|
this.continuationScheduled = false
|
|
if (this.continuationHandleAssigned) {
|
|
this.cancelScheduled(this.continuationHandle)
|
|
}
|
|
this.continuationHandleAssigned = false
|
|
this.continuationHandle = undefined
|
|
}
|
|
|
|
private acquirePause(): void {
|
|
if (!this.paused) {
|
|
this.paused = true
|
|
try {
|
|
this.pause?.()
|
|
} catch (error) {
|
|
this.paused = false
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
private releasePause(): void {
|
|
if (this.paused) {
|
|
this.paused = false
|
|
this.resume?.()
|
|
}
|
|
}
|
|
}
|
|
|
|
const positiveLimit = (value: number | undefined, fallback: number): number =>
|
|
value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback
|