fix(schedule): validate exact time-context marker shapes

This commit is contained in:
pku-xht
2026-08-07 20:55:58 +08:00
committed by Tianyi Cui
parent 081b2819e3
commit de3e0a5d1c
4 changed files with 177 additions and 16 deletions
+19 -9
View File
@@ -67,11 +67,19 @@ function validateReading(
event: SessionEvent<'user/message'>,
fail: InvariantFailure,
): void {
const [block] = event.data.content
if (event.data.content.length !== 1 || block?.type !== 'text') {
const blockValue: unknown = event.data.content[0]
const block = typeof blockValue === 'object' && blockValue !== null
? blockValue as Record<string, unknown>
: undefined
const blockText = block?.text
if (event.data.content.length !== 1
|| block === undefined
|| Object.keys(block).length !== 2
|| block.type !== 'text'
|| typeof blockText !== 'string') {
fail('time-context messages must contain exactly one text block')
}
const match = READING.exec(block.text)
const match = READING.exec(blockText)
if (match === null) fail('time-context message does not match the durable reading format')
const turn = Number(match[1])
const step = Number(match[2])
@@ -88,17 +96,19 @@ function validateReading(
fail('time-context source must retain package ownership')
}
const sections: unknown = 'sections' in source ? source.sections : undefined
const section: unknown = Array.isArray(sections) ? sections[0] : undefined
const sectionValue: unknown = Array.isArray(sections) ? sections[0] : undefined
const section = typeof sectionValue === 'object' && sectionValue !== null
? sectionValue as Record<string, unknown>
: undefined
if (Object.keys(source).length !== 4
|| source.form !== 'snapshot'
|| !Array.isArray(sections)
|| sections.length !== 1
|| typeof section !== 'object'
|| section === null
|| !('name' in section)
|| section === undefined
|| Object.keys(section).length !== 2
|| section.name !== SOURCE_NAME
|| !('text' in section)
|| section.text !== block.text) {
|| typeof section.text !== 'string'
|| section.text !== blockText) {
fail('time-context source must carry only the exact snapshot text, not request authority')
}
const renderedAuthority = `Session time zone: ${match[4]}.\nClient time zone for this request: ${match[5]}.`
@@ -155,6 +155,90 @@ describe('time-context invariants', () => {
}).toThrow(/must carry only the exact snapshot text/)
})
it('rejects snapshot provenance whose section differs from the model-visible text', async () => {
const ctx = await setup()
const base = event(reading())
const mismatched: SessionEvent<'user/message'> = {
...base,
data: {
...base.data,
source: {
kind: 'plugin',
plugin: 'time-context',
form: 'snapshot',
sections: [{ name: 'time-context', text: 'different' }],
},
},
}
expect(() => {
ctx.emit('session/event', preparing(1, 1), mismatched)
}).toThrow(/must carry only the exact snapshot text/)
})
it('rejects snapshot provenance whose sections are only array-like', async () => {
const ctx = await setup()
const base = event(reading())
const arrayLike: SessionEvent<'user/message'> = {
...base,
data: {
...base.data,
source: {
kind: 'plugin',
plugin: 'time-context',
form: 'snapshot',
sections: { 0: { name: 'time-context', text: reading() }, length: 1 },
} as never,
},
}
expect(() => {
ctx.emit('session/event', preparing(1, 1), arrayLike)
}).toThrow(/must carry only the exact snapshot text/)
})
it.each([
[
'matched non-string text',
{ type: 'text', text: 7 },
[{ name: 'time-context', text: 7 }],
/must contain exactly one text block/,
],
[
'an extra text-block field',
{ type: 'text', text: reading(), extra: true },
[{ name: 'time-context', text: reading() }],
/must contain exactly one text block/,
],
[
'non-string section text',
{ type: 'text', text: reading() },
[{ name: 'time-context', text: 7 }],
/must carry only the exact snapshot text/,
],
[
'an extra section field',
{ type: 'text', text: reading() },
[{ name: 'time-context', text: reading(), extra: true }],
/must carry only the exact snapshot text/,
],
] as const)(
'rejects snapshot provenance with %s',
async (_name, block, sections, diagnostic) => {
const ctx = await setup()
const base = event(reading())
const malformed: SessionEvent<'user/message'> = {
...base,
data: {
...base.data,
content: [block as never],
source: { kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections } as never,
},
}
expect(() => {
ctx.emit('session/event', preparing(1, 1), malformed)
}).toThrow(diagnostic)
},
)
it('rejects package-owned provenance without snapshot sections', async () => {
const ctx = await setup()
const base = event(reading())
+15 -7
View File
@@ -226,18 +226,26 @@ function isTimeContextReading(event: SessionEvent): boolean {
|| source.plugin !== 'time-context'
|| Object.keys(source).length !== 4
|| source.form !== 'snapshot') return false
const [block] = event.data.content
const blockValue: unknown = event.data.content[0]
const block = typeof blockValue === 'object' && blockValue !== null
? blockValue as Record<string, unknown>
: undefined
const sections: unknown = source.sections
const section: unknown = Array.isArray(sections) ? sections[0] : undefined
const sectionValue: unknown = Array.isArray(sections) ? sections[0] : undefined
const section = typeof sectionValue === 'object' && sectionValue !== null
? sectionValue as Record<string, unknown>
: undefined
return event.data.content.length === 1
&& block?.type === 'text'
&& block !== undefined
&& Object.keys(block).length === 2
&& block.type === 'text'
&& typeof block.text === 'string'
&& Array.isArray(sections)
&& sections.length === 1
&& typeof section === 'object'
&& section !== null
&& 'name' in section
&& section !== undefined
&& Object.keys(section).length === 2
&& section.name === 'time-context'
&& 'text' in section
&& typeof section.text === 'string'
&& section.text === block.text
}
@@ -359,6 +359,65 @@ describe('Schedule tool protocol', () => {
})
})
it('does not let an array-like snapshot marker authorize an implicit local at', async () => {
const test = await harness(true, 'Asia/Shanghai')
test.agent.session.append('turn/start', { turn: 1 })
test.agent.session.append('step/start', { turn: 1, step: 1 })
test.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'request' }],
source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never,
}), { surfaceOp: 'append' })
const text = 'time context'
test.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: {
kind: 'plugin',
plugin: 'time-context',
form: 'snapshot',
sections: { 0: { name: 'time-context', text }, length: 1 },
} as never,
}), { surfaceOp: 'append' })
expect(value(await execute(test, 'schedule_create', {
prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' },
}))).toMatchObject({
code: 'timezone_confirmation_required',
sessionTimeZone: 'Asia/Shanghai',
clientTimeZones: [],
})
})
it.each([
['a non-object text block', 7, [{ name: 'time-context', text: 'time context' }]],
['matched non-string text', { type: 'text', text: 7 }, [{ name: 'time-context', text: 7 }]],
['extra text-block field', { type: 'text', text: 'time context', extra: true }, [{ name: 'time-context', text: 'time context' }]],
['non-string section text', { type: 'text', text: 'time context' }, [{ name: 'time-context', text: 7 }]],
['extra section field', { type: 'text', text: 'time context' }, [{ name: 'time-context', text: 'time context', extra: true }]],
] as const)(
'does not let snapshot provenance with %s authorize an implicit local at',
async (_name, block, sections) => {
const test = await harness(true, 'Asia/Shanghai')
test.agent.session.append('turn/start', { turn: 1 })
test.agent.session.append('step/start', { turn: 1, step: 1 })
test.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'request' }],
source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' } as never,
}), { surfaceOp: 'append' })
test.agent.session.append('user/message', createUserMessage({
content: [block as never],
source: { kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections } as never,
}), { surfaceOp: 'append' })
expect(value(await execute(test, 'schedule_create', {
prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' },
}))).toMatchObject({
code: 'timezone_confirmation_required',
sessionTimeZone: 'Asia/Shanghai',
clientTimeZones: [],
})
},
)
it.each(['step/end', 'turn/end'] as const)(
'fails closed after the current %s boundary',
async (boundary) => {