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:
1 parent
b7741360ed
commit
6275ab00d7
6 files changed
+104
-4
No files matched your search
+27
-3
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user