Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,13 @@
> Heavily inspired by [Neuro-sama](https://www.youtube.com/@Neurosama)

> [!TIP]
> On Windows, you can also install AIRI with [Scoop](https://scoop.sh/):
> On Windows, you can also install AIRI with [winget](https://learn.microsoft.com/windows/package-manager/winget/):
>
> ```powershell
> winget install MoeruAI.AIRI
> ```
>
> Or install AIRI with [Scoop](https://scoop.sh/):
>
> ```powershell
> scoop bucket add airi https://github.com/moeru-ai/airi
Expand Down
8 changes: 7 additions & 1 deletion docs/README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,13 @@
> Fortement inspiré par [Neuro-sama](https://www.youtube.com/@Neurosama)

> [!TIP]
> Sous Windows, vous pouvez également installer AIRI avec [Scoop](https://scoop.sh/) :
> Sous Windows, vous pouvez installer AIRI avec [winget](https://learn.microsoft.com/windows/package-manager/winget/) :
>
> ```powershell
> winget install MoeruAI.AIRI
> ```
>
> Ou installer AIRI avec [Scoop](https://scoop.sh/) :
>
> ```powershell
> scoop bucket add airi https://github.com/moeru-ai/airi
Expand Down
8 changes: 7 additions & 1 deletion docs/README.ja-JP.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,13 @@
> [Neuro-sama](https://www.youtube.com/@Neurosama) に大きな影響を受けました

> [!TIP]
> Windows では、[Scoop](https://scoop.sh/) でも AIRI をインストールできます:
> Windows では、[winget](https://learn.microsoft.com/windows/package-manager/winget/) で AIRI をインストールできます:
>
> ```powershell
> winget install MoeruAI.AIRI
> ```
>
> または、[Scoop](https://scoop.sh/) で AIRI をインストールできます:
>
> ```powershell
> scoop bucket add airi https://github.com/moeru-ai/airi
Expand Down
8 changes: 7 additions & 1 deletion docs/README.ko-KR.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,13 @@
> [Neuro-sama](https://www.youtube.com/@Neurosama)에서 큰 영감을 받았습니다

> [!TIP]
> Windows에서는 [Scoop](https://scoop.sh/)으로도 AIRI를 설치할 수 있습니다:
> Windows에서는 [winget](https://learn.microsoft.com/windows/package-manager/winget/)으로 AIRI를 설치할 수 있습니다:
>
> ```powershell
> winget install MoeruAI.AIRI
> ```
>
> 또는 [Scoop](https://scoop.sh/)으로 AIRI를 설치할 수 있습니다:
>
> ```powershell
> scoop bucket add airi https://github.com/moeru-ai/airi
Expand Down
8 changes: 7 additions & 1 deletion docs/README.ru-RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,13 @@
> Сильно вдохновлено [Neuro-sama](https://www.youtube.com/@Neurosama)

> [!TIP]
> В Windows AIRI также можно установить с помощью [Scoop](https://scoop.sh/):
> В Windows AIRI можно установить с помощью [winget](https://learn.microsoft.com/windows/package-manager/winget/):
>
> ```powershell
> winget install MoeruAI.AIRI
> ```
>
> Или установить AIRI через [Scoop](https://scoop.sh/):
>
> ```powershell
> scoop bucket add airi https://github.com/moeru-ai/airi
Expand Down
8 changes: 7 additions & 1 deletion docs/README.vi.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,13 @@
> Lấy cảm hứng mạnh mẽ từ [Neuro-sama](https://www.youtube.com/@Neurosama)

> [!TIP]
> Trên Windows, bạn cũng có thể cài AIRI bằng [Scoop](https://scoop.sh/):
> Trên Windows, bạn có thể cài AIRI bằng [winget](https://learn.microsoft.com/windows/package-manager/winget/):
>
> ```powershell
> winget install MoeruAI.AIRI
> ```
>
> Hoặc cài AIRI bằng [Scoop](https://scoop.sh/):
>
> ```powershell
> scoop bucket add airi https://github.com/moeru-ai/airi
Expand Down
8 changes: 7 additions & 1 deletion docs/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,13 @@
> 深受 [Neuro-sama](https://www.youtube.com/@Neurosama) 启发

> [!TIP]
> 在 Windows 上,你也可以使用 [Scoop](https://scoop.sh/) 安装 AIRI:
> 在 Windows 上,你也可以使用 [winget](https://learn.microsoft.com/windows/package-manager/winget/) 安装 AIRI:
>
> ```powershell
> winget install MoeruAI.AIRI
> ```
>
> 或者使用 [Scoop](https://scoop.sh/) 安装 AIRI:
>
> ```powershell
> scoop bucket add airi https://github.com/moeru-ai/airi
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'

import { sanitizeCloneable } from './context-bridge-sanitize'

describe('sanitizeCloneable', () => {
it('preserves cloneable bigint values', () => {
const input = {
metadata: {
count: 3n,
nested: [1n, { latest: 5n }],
},
}

const sanitized = sanitizeCloneable(input)
const cloned = structuredClone(sanitized)

expect(cloned).toEqual({
metadata: {
count: 3n,
nested: [1n, { latest: 5n }],
},
})
})

it('removes nested non-cloneable values and stays structuredClone-safe', () => {
const input = {
text: 'vision update',
metadata: {
safe: 'ok',
nested: {
window: globalThis,
fn: () => 'bad',
arr: [1, { keep: true, drop: globalThis }],
},
},
}

const sanitized = sanitizeCloneable(input)
const cloned = structuredClone(sanitized)

expect(cloned).toEqual({
text: 'vision update',
metadata: {
safe: 'ok',
nested: {
arr: [1, { keep: true }],
},
},
})
})
})
61 changes: 61 additions & 0 deletions packages/stage-ui/src/stores/mods/api/context-bridge-sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
export function sanitizeCloneable(value: unknown, seen = new WeakSet<object>()): unknown {
if (value == null)
return value
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint')
return value
if (typeof value === 'symbol' || typeof value === 'function')
return undefined

const rawValue = value

if (Array.isArray(rawValue)) {
return rawValue
.map(item => sanitizeCloneable(item, seen))
.filter(item => item !== undefined)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve cloneable undefined values

When context metadata or content intentionally contains undefined (for example as an array placeholder or Map value), it is already structured-cloneable, but using undefined as the removal sentinel makes this filter and the analogous Map/object filters drop it. For positional arrays this also shifts later entries, so broadcast stream snapshots can no longer match the sender's context even though the original value was clone-safe.

Useful? React with 👍 / 👎.

Comment on lines +11 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track arrays before recursing into their items

When a context value contains a cycle through an array, such as const a = []; a.push(a), this branch recurses into the array before recording it in seen, so the guard below never runs and sanitizeCloneable throws RangeError: Maximum call stack size exceeded. That means a cloneable or intentionally circular context snapshot can still crash the stream broadcast path instead of being sanitized as intended for circular references.

Useful? React with 👍 / 👎.

}

if (rawValue instanceof Date)
return rawValue.toISOString()

if (rawValue instanceof RegExp)
return rawValue.toString()
Comment on lines +17 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve cloneable Date and RegExp values

Fresh evidence in this revision is that Date and RegExp are still explicitly converted here even after other cloneable built-ins were preserved. When a hook stores one of these values in ContextMessage.content or metadata, the previous structuredClone(...) path would deliver the same built-in type, but this sanitizer changes it to a string before broadcasting, breaking consumers that expect date or regexp behavior.

Useful? React with 👍 / 👎.


if (typeof rawValue !== 'object')
return rawValue

if (seen.has(rawValue))
return undefined
Comment on lines +26 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve repeated cloneable objects

When a single context message reuses the same object in two places, such as metadata: { a: shared, b: shared } or a Map whose key and value are the same object, structuredClone would broadcast both references successfully. This WeakSet treats the second visit as a cycle and returns undefined, so the sanitizer drops the later field or entry and corrupts otherwise cloneable context snapshots; it should distinguish true recursion from shared references, or cache cloned values.

Useful? React with 👍 / 👎.

seen.add(rawValue)

if (rawValue instanceof Map) {
return new Map(
[...rawValue.entries()]
.map(([key, nestedValue]) => [
sanitizeCloneable(key, seen),
sanitizeCloneable(nestedValue, seen),
] as const)
.filter(([key, nestedValue]) => key !== undefined && nestedValue !== undefined),
)
}

if (rawValue instanceof Set) {
return new Set(
[...rawValue.values()]
.map(item => sanitizeCloneable(item, seen))
.filter(item => item !== undefined),
)
}

if (ArrayBuffer.isView(rawValue) || rawValue instanceof ArrayBuffer)
return rawValue

const proto = Object.getPrototypeOf(rawValue)
if (proto !== Object.prototype && proto !== null)
return undefined
Comment on lines +53 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve structured-cloneable built-ins

When a local hook or context provider puts a structured-cloneable non-plain object such as Map, Set, Blob, or ArrayBuffer into ContextMessage.content, the registry and previous stream broadcast path could carry it via structuredClone. This branch now returns undefined solely because the prototype is not Object.prototype, so those fields or array entries are silently removed from stream events even though they were valid cloneable context data.

Useful? React with 👍 / 👎.


return Object.fromEntries(
Object.entries(rawValue)
.map(([key, nestedValue]) => [key, sanitizeCloneable(nestedValue, seen)] as const)
.filter(([, nestedValue]) => nestedValue !== undefined),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,44 @@
* @example
* Input context updates record store-ingested and stay in chat input payload.
*/
it('sanitizes nested non-cloneable context values before broadcasting stream events', async () => {
activeProviderRef.value = 'mock-provider'
activeModelRef.value = 'mock-model'
getProviderInstanceMock.mockResolvedValueOnce({})
const store = useContextBridgeStore()
await store.initialize()

const streamMessages = collectChannelMessages<any>(CHAT_STREAM_CHANNEL_NAME)
const unsafeContext = {
contexts: {
vision: [
{
text: 'vision update',
metadata: {
safe: 'ok',
nested: { window: globalThis, fn: () => 'bad' },
},
},
],
},
}

await emitHooks(beforeComposeHooks, { role: 'user', content: 'hello' }, unsafeContext)
await waitForBroadcastDelivery()

expect(streamMessages).toHaveLength(1)
expect(streamMessages[0].type).toBe('before-compose')
expect(streamMessages[0].context.contexts.vision[0]).toEqual({
text: 'vision update',
metadata: {
safe: 'ok',
nested: {},
},
})

await store.dispose()
})

it('records core ingest result for input context updates and forwards accepted updates', async () => {
chatContextIngestMock.mockReturnValueOnce({
sourceKey: 'weather:station-1',
Expand Down Expand Up @@ -638,4 +676,26 @@

await store.dispose()
})

it('preserves bigint values in broadcast context snapshots', async () => {
const messages = collectChannelMessages<unknown>(CONTEXT_CHANNEL_NAME)

const store = useContextBridgeStore()
await store.initialize()

await emitContextUpdate(createContextUpdateEvent({
metadata: { count: 7n, nested: [{ total: 9n }] },
}))
await waitForBroadcastDelivery()

Check failure on line 690 in packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts

View workflow job for this annotation

GitHub Actions / Unit Test

[browser (chromium)] src/stores/mods/api/context-bridge.contract.browser.test.ts > context bridge contract > preserves bigint values in broadcast context snapshots

AssertionError: expected { id: 'context-1', …(5) } to match object { id: 'context-1', …(1) } (7 matching properties omitted from actual) - Expected + Received { "id": "context-1", "metadata": { - "count": 7n, - "nested": [ - { - "total": 9n, + "source": { + "extension": { + "id": "weather", }, - ], + "id": "station-1", + }, }, } ❯ src/stores/mods/api/context-bridge.contract.browser.test.ts:690:28
expect(messages.at(-1)).toMatchObject({
id: 'context-1',
metadata: {
count: 7n,
nested: [{ total: 9n }],
},
})

await store.dispose()
})
})
5 changes: 4 additions & 1 deletion packages/stage-ui/src/stores/mods/api/context-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useLlmStreamingControlStore } from '../../llm-streaming-control'
import { useConsciousnessStore } from '../../modules/consciousness'
import { useProvidersStore } from '../../providers'
import { useModsServerChannelStore } from './channel-server'
import { sanitizeCloneable } from './context-bridge-sanitize'

export function normalizeContextSnapshot<C extends Pick<ChatStreamEventContext, 'contexts'>>(contexts: C): C {
return {
Expand All @@ -35,7 +36,9 @@ export function normalizeContextSnapshot<C extends Pick<ChatStreamEventContext,
.entries(toRaw(contexts.contexts))
.map(([key, ctx]) => [
key,
ctx.map(c => toRaw(c)),
ctx
.map(c => sanitizeCloneable(c))
.filter((value): value is NonNullable<typeof value> => value !== undefined),
]),
),
}
Expand Down
8 changes: 8 additions & 0 deletions vitest.sanitize.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
environment: 'node',
include: ['packages/stage-ui/src/stores/mods/api/context-bridge-sanitize.test.ts'],
},
})
Loading