fix(host): keep the projection folds total over untrusted session-log events

The projection registry drives apply straight off the session/event bus
with no error boundary: one throwing fold stalls the unit's cells and
the contextTimeline push feed — the browser waits on "loading" forever.
Three real throw paths existed, and one of them also broke the
plain-JSON checkpoint precondition (one undefined-valued property fails
EVERY projection-cache write for the session):

- applyTimeline now isolates each event: a malformed one is dropped
  whole (all-or-nothing — the state reference is untouched), so no
  event shape, present or future, can take the fold down.
- A tool/result with no paired tool/call (aged-out call event, foreign
  producer, duplicate callId) no longer stamps an undefined-valued
  node.tool onto the surface node.
- pricing and header-epoch folds tolerate null or primitive elements
  inside content-block and tool arrays instead of throwing on them.

Hostile-fixture tests lock each path: a log missing data.message, null
blocks nesting inside tool-result content, unpaired results, and junk
tool entries — every fold state still losslessly JSON-serializable.
This commit is contained in:
bowenliang123 committed 2026-08-28 00:06:02 +08:00
1 parent b7741360ed
commit 6275ab00d7
6 files changed
+104 -4

No files matched your search

+27 -3
View File
@@ -243,6 +243,7 @@ interface MessageLike {
function nestedText(blocks: ContentBlock[] | undefined): string {
if (!Array.isArray(blocks)) return ''
for (const block of blocks) {
if (block === null || typeof block !== 'object') continue
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') return block.text
if (block.content !== undefined) {
const nested = nestedText(block.content)
@@ -302,8 +303,16 @@ function applySurface(
const srcName = typeof srcId === 'string' ? st.callNames[srcId] : undefined
const block = message?.content?.[0] as { toolCallId?: unknown } | undefined
const blockId = block?.toolCallId
if (srcName) node.tool = srcName
else if (typeof blockId === 'string') node.tool = st.callNames[blockId]
// The name is stamped only on a real map hit: an unpaired result (a call
// event that aged out of the log, a foreign producer, a duplicate callId)
// must not materialize an `undefined`-valued property — that one property
// fails EVERY projection-cache write for the session (the plain-JSON
// precondition, see TimelineState).
if (srcName !== undefined) node.tool = srcName
else if (typeof blockId === 'string') {
const blockName = st.callNames[blockId]
if (blockName !== undefined) node.tool = blockName
}
// Consume-once: the entry is never looked up again after its result
// folds in (see TimelineState.callNames). Rebuild without the used ids
// (no dynamic delete, per repo lint) — consume-once holds the map at
@@ -470,7 +479,14 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
}
const data = event.data
switch (event.type) {
// The projection registry drives `apply` straight off the session/event bus
// with no error boundary: one throwing fold stops this unit's cells (and the
// `contextTimeline` push feed) from advancing — the browser waits on
// "loading" forever. The durable log is untrusted input, so a malformed
// event is DROPPED, never thrown; any partial mutations are private lazy
// clones and stay valid plain JSON.
try {
switch (event.type) {
case 'request/header': {
const header = (data?.header ?? {}) as {
system?: unknown
@@ -641,6 +657,14 @@ export function applyTimeline(state: TimelineState, event: TimelineEvent, bounds
}
default:
return state
}
} catch {
// Unreachable over well-formed events; the guard exists so it can never
// take the projection (or the session event bus) down. A failed event is
// dropped WHOLE: any partial mutation lived on private lazy clones, so
// falling back to the previous state reference keeps the transition
// all-or-nothing.
st = undefined
}
if (st !== undefined) {
+3 -1
View File
@@ -62,7 +62,9 @@ function recordOf(event: SessionEvent): HeaderRecord | null {
seq: event.seq,
time: event.time,
tools: tools.map((t): HeaderTool => {
const tool = t as { name?: unknown; description?: unknown }
// The log is untrusted input: a null or primitive entry degrades to an
// unnamed, JSON-priced tool instead of throwing the fold.
const tool = (t !== null && typeof t === 'object' ? t : {}) as { name?: unknown; description?: unknown }
const entry: HeaderTool = {
name: typeof tool.name === 'string' ? tool.name : '?',
tokens: estimateToolSchema(t),
+9
View File
@@ -40,6 +40,12 @@ function estimateBlocks(blocks: ContentBlock[] | undefined): number {
let tokens = 0
if (!Array.isArray(blocks)) return 0
for (const block of blocks) {
// The log is untrusted input: a null or primitive element prices as bare
// overhead instead of throwing the whole fold.
if (block === null || typeof block !== 'object') {
tokens += BLOCK_OVERHEAD
continue
}
switch (block.type) {
case 'text':
case 'reasoning':
@@ -99,6 +105,7 @@ export function imageCountOf(blocks: ContentBlock[] | undefined): number {
let count = 0
if (!Array.isArray(blocks)) return 0
for (const block of blocks) {
if (block === null || typeof block !== 'object') continue
if (block.type === 'image') count++
else if (Array.isArray(block.content)) count += imageCountOf(block.content)
}
@@ -108,6 +115,7 @@ export function imageCountOf(blocks: ContentBlock[] | undefined): number {
export function firstText(blocks: ContentBlock[] | undefined): string {
if (!Array.isArray(blocks)) return ''
for (const b of blocks) {
if (b === null || typeof b !== 'object') continue
if (b.type === 'text' && typeof b.text === 'string' && b.text.trim() !== '') {
return b.text.replace(/\s+/g, ' ').trim().slice(0, 80)
}
@@ -119,6 +127,7 @@ export function toolCallNames(blocks: ContentBlock[] | undefined): string[] {
const names: string[] = []
if (!Array.isArray(blocks)) return names
for (const b of blocks) {
if (b === null || typeof b !== 'object') continue
if (b.type === 'tool-call' && typeof b.name === 'string') names.push(b.name)
}
return names
+32
View File
@@ -498,3 +498,35 @@ describe('session-cost accumulation', () => {
assert.deepEqual(state.cost?.pro?.peak, { uncached: 10, cacheRead: 20, cacheWrite: 30, output: 40 })
})
})
describe('hostile events', () => {
test('a malformed assistant/message is dropped instead of taking the fold down', () => {
const def = timelineDef({})
// deriveEventMessage dereferences data.message.content — a log without it
// must not throw out of apply (the projection registry has no net; a
// throwing fold stalls the unit and the browser waits on loading forever).
const init = def.init()
const next = def.apply(init, { type: 'assistant/message', seq: 1, time: at(), data: {} } as TimelineEvent)
assert.equal(next, init, 'a failed event leaves the state untouched')
// The fold carries on: the next well-formed event still lands.
const then = def.apply(next, assistantMessage(2, { turn: 1, step: 1 }))
assert.equal(then.surface.length, 1)
assert.equal(then.requests.length, 1)
assertPlainJson(then)
})
test('null and primitive content blocks price and render as nothing', () => {
const blocks = [null, 7, 'x', { type: 'text', text: 'hello' }] as never
// The tool/result has NO preceding tool/call — the unpaired lookup must
// not stamp an `undefined`-valued node.tool (it would fail every
// projection-cache write for the session).
const { state } = driveTimeline([
userMessage(1, blocks),
toolResult(2, { callId: 'c1', content: blocks }),
assistantMessage(3, { content: blocks }),
])
// The text block still prices; the junk blocks cost overhead only.
assert.ok(state.sums.user > 0)
assertPlainJson(state)
})
})
+19
View File
@@ -156,3 +156,22 @@ describe('createContextHeadersDefinition', () => {
assert.equal(c.wire.viewSchema.safeParse(c.wire.view(state)).success, true)
})
})
describe('hostile tool entries', () => {
test('null and primitive tool entries degrade to unnamed JSON-priced tools', () => {
const def = createContextHeadersDefinition()
const view = def.view(fold(def, [headerEvent(1, {
tools: [null, 42, 'x', { name: 'bash' }],
})]))
const tools = view.headers[0].tools
assert.equal(tools.length, 4)
assert.equal(tools[0].name, '?')
assert.equal(tools[1].name, '?')
assert.equal(tools[2].name, '?')
assert.equal(tools[3].name, 'bash')
for (const tool of tools) {
assert.ok(Number.isInteger(tool.tokens) && tool.tokens >= 0, 'tool tokens priced')
assert.ok('schema' in tool, 'the raw schema rides the record')
}
})
})
+14
View File
@@ -246,3 +246,17 @@ describe('isInjection', () => {
assert.equal(isInjection({ form: 'notice' }), true)
})
})
describe('hostile block shapes', () => {
test('null and primitive elements degrade to overhead instead of throwing', () => {
const junk = [null, 7, 'x'] as never
assert.ok(estimateMessage({ content: junk }) > 0)
assert.equal(imageCountOf(junk), 0)
assert.equal(firstText(junk), '')
assert.deepEqual(toolCallNames(junk), [])
// A null element nested inside a tool-result block is tolerated too.
const nested = [{ type: 'tool-result', content: [null, { type: 'image' }] }] as never
assert.equal(imageCountOf(nested), 1)
assert.ok(estimateMessage({ content: nested }) > 0)
})
})