From bca7cfd9302855d4a1122a312935c39880d4b7ae Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:43:26 -0700 Subject: [PATCH 01/10] feat: validate agent-authored UI card payloads at write Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- backend/Dockerfile | 3 + .../agentsRuntime.post-message.config.test.js | 36 ++++++ ...MessageService.threadRootBroadcast.test.js | 34 ++++++ .../unit/services/uiCardValidation.test.js | 64 ++++++++++ backend/config/uiCardCatalogue.json | 10 ++ backend/package-lock.json | 50 ++++++++ backend/package.json | 1 + backend/routes/agentsRuntime.ts | 12 +- backend/services/uiCardValidation.ts | 115 ++++++++++++++++++ cli/__tests__/pod-send.test.mjs | 47 +++++++ cli/src/commands/pod.js | 19 ++- commonly-mcp/__tests__/tools.test.mjs | 9 ++ commonly-mcp/src/tools.js | 6 +- docs/api/agent-ui-cards.md | 62 ++++++++++ 14 files changed, 458 insertions(+), 10 deletions(-) create mode 100644 backend/__tests__/unit/services/uiCardValidation.test.js create mode 100644 backend/config/uiCardCatalogue.json create mode 100644 backend/services/uiCardValidation.ts create mode 100644 cli/__tests__/pod-send.test.mjs create mode 100644 docs/api/agent-ui-cards.md diff --git a/backend/Dockerfile b/backend/Dockerfile index 37f76c0536..917cffff65 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -49,6 +49,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certifi officecli --version && \ apt-get clean && rm -rf /var/lib/apt/lists/* +# Parse stored agent cards locally; never send install/runtime telemetry. +ENV OPENUI_TELEMETRY_DISABLED=1 + COPY package*.json ./ # Install all dependencies including dev dependencies diff --git a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js index f249dc9028..b3c722624a 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js @@ -218,6 +218,42 @@ describe('agentsRuntime post message installation config forwarding', () => { }); }); +describe('agent card route boundary', () => { + const installation = { podId: 'pod-1', agentName: 'openclaw', instanceId: 'default', config: {} }; + const payload = { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; + const handler = getRouteHandler('/pods/:podId/messages', 'post'); + const req = (body) => ({ params: { podId: 'pod-1' }, body: { continueAnyway: true, ...body }, + agentInstallation: installation, agentInstallations: [installation] }); + const response = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() }); + beforeEach(() => { jest.clearAllMocks(); findNonMemberMentionNotices.mockResolvedValue([]); }); + it('forwards a validated UI payload unchanged to the durable message writer', async () => { + AgentMessageService.postMessage.mockResolvedValue({ success: true }); + const res = response(); + await handler(req({ messageType: 'card', payload, content: 'fallback' }), res); + expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + messageType: 'card', payload, content: 'fallback', + })); + expect(res.status).not.toHaveBeenCalled(); + }); + it.each([ + { ...payload, kind: 'approval' }, + { ...payload, source: 'root = Text("hello")\nq = Query("secret", {})' }, + { ...payload, source: 'root = Unknown("hello")' }, + ])('refuses unsafe cards before persistence or mention fan-out %#', async (badPayload) => { + const res = response(); + await handler(req({ messageType: 'card', payload: badPayload, content: 'fallback' }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); + expect(findNonMemberMentionNotices).not.toHaveBeenCalled(); + }); + it('keeps the pod authorization check ahead of parsing', async () => { + const res = response(); + await handler({ ...req({ payload }), params: { podId: 'other' } }, res); + expect(res.status).toHaveBeenCalledWith(403); + expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); + }); +}); + describe('bot-token send-time freshness', () => { const user = { _id: 'bot-1', username: 'openclaw', isBot: true, diff --git a/backend/__tests__/unit/services/agentMessageService.threadRootBroadcast.test.js b/backend/__tests__/unit/services/agentMessageService.threadRootBroadcast.test.js index 9e77c8d5ce..585e8d0174 100644 --- a/backend/__tests__/unit/services/agentMessageService.threadRootBroadcast.test.js +++ b/backend/__tests__/unit/services/agentMessageService.threadRootBroadcast.test.js @@ -207,3 +207,37 @@ describe('agent broadcast carries author identity', () => { })); }); }); + + +describe('agent UI card payload persistence and live delivery', () => { + const payload = { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; + it('passes the envelope to PostgreSQL and preserves it in result and socket', async () => { + PGMessage.create.mockResolvedValue(pgRow({ payload, content: 'UI text fallback', message_type: 'card', thread_root_id: null })); + const result = await AgentMessageService.postMessage({ + agentName: 'sprint-review', podId: POD, content: 'UI text fallback', messageType: 'card', payload, + }); + expect(PGMessage.create).toHaveBeenCalledWith( + POD, 'agent-user-1', 'UI text fallback', 'card', null, payload, null, undefined, + ); + expect(Message).not.toHaveBeenCalled(); + expect(result.message.payload).toEqual(payload); + expect(emitted.find((e) => e.event === 'newMessage').payload).toEqual(expect.objectContaining({ + messageType: 'card', payload, content: 'UI text fallback', + })); + }); + it('preserves the same envelope in Mongo fallback and live delivery', async () => { + delete process.env.PG_HOST; + const save = jest.fn().mockResolvedValue(undefined); + Message.mockImplementation((data) => ({ + ...data, _id: 'mongo-card-1', createdAt: new Date(), save, + populate: jest.fn().mockResolvedValue(undefined), + })); + const result = await AgentMessageService.postMessage({ + agentName: 'sprint-review', podId: POD, content: 'UI text fallback', messageType: 'card', payload, + }); + expect(Message).toHaveBeenCalledWith(expect.objectContaining({ messageType: 'card', payload })); + expect(save).toHaveBeenCalledTimes(1); + expect(result.message.payload).toEqual(payload); + expect(emitted.find((e) => e.event === 'newMessage').payload.payload).toEqual(payload); + }); +}); diff --git a/backend/__tests__/unit/services/uiCardValidation.test.js b/backend/__tests__/unit/services/uiCardValidation.test.js new file mode 100644 index 0000000000..27cb541c19 --- /dev/null +++ b/backend/__tests__/unit/services/uiCardValidation.test.js @@ -0,0 +1,64 @@ +const { validateAgentCardPayload, UI_CARD_MAX_BYTES } = require('../../../services/uiCardValidation'); + +const card = (source = 'root = Text("hello")') => ({ kind: 'ui', v: 1, lang: 'openui@1', source }); +const validate = (payload = card(), content = 'Text fallback', type = 'card') => ( + validateAgentCardPayload(type, payload, content) +); + +describe('untrusted UI card write validation', () => { + it('keeps ordinary text backwards compatible', () => { + expect(() => validateAgentCardPayload(undefined, undefined, 'hello')).not.toThrow(); + }); + it('accepts a complete known component without evaluating it', () => { + expect(() => validate()).not.toThrow(); + expect(() => validate(card('root = Text("Query Mutation @Run are just quoted text")'))).not.toThrow(); + }); + it.each([ + null, [], {}, { ...card(), kind: 'approval' }, { ...card(), kind: 'decision' }, + { ...card(), v: 2 }, { ...card(), lang: 'openui@2' }, + { ...card(), source: 5 }, { ...card(), source: '' }, + { ...card(), tools: { secret: 'no' } }, { ...card(), runId: 'forged' }, + ])('refuses an unsupported or privileged envelope %#', (payload) => { + expect(() => validate(payload)).toThrow(expect.objectContaining({ statusCode: 400, code: 'invalid_ui_card' })); + }); + it.each([undefined, null, '', ' ', 42])('requires a text fallback (%s)', (content) => { + expect(() => validateAgentCardPayload('card', card(), content)).toThrow(); + }); + it('requires the explicit card type and a card payload', () => { + expect(() => validate(card(), 'fallback', 'text')).toThrow(); + expect(() => validateAgentCardPayload('card', undefined, 'fallback')).toThrow(); + }); + it.each([ + 'root = Unknown("hello")', + 'root = Text("hello")\nunused = Unknown("hello")', + 'root = Query("tool", {})', + 'root = Text("hello")\nunused = Query("tool", {})', + 'root = Text("hello")\nunused = Mutation("tool", {})', + 'root = Text(Action(@Run(q)))', + 'root = Text("hello")\nunused = Action(@Run(q))', + 'root = Text("hello") Query("tool", {})', + 'root = Text("hello") Unknown("hidden")', + 'root = Text(@Fetch("https://example.org"))', + '$state = "x"\nroot = Text($state)', + 'root = Text("hello"', + 'root = Text(missing)', + 'root = Text("hi")\nroot = Text("replacement")', + 'root = Text({__proto__: {polluted: true}})', + 'no root here', + ])('refuses unsafe, unknown, or incomplete source: %s', (source) => { + expect(() => validate(card(source))).toThrow(expect.objectContaining({ statusCode: 400 })); + }); + it('refuses deeply nested input before recursive parsing', () => { + expect(() => validate(card('root = Text(' + '['.repeat(1000) + '0' + ']'.repeat(1000) + ')'))).toThrow('nesting'); + }); + it('limits serialized payload bytes, including multibyte Unicode and exact boundary', () => { + const prefix = 'root = Text("'; + const suffix = '")'; + const overhead = Buffer.byteLength(JSON.stringify(card(prefix + suffix))); + const exact = card(prefix + 'a'.repeat(UI_CARD_MAX_BYTES - overhead) + suffix); + expect(Buffer.byteLength(JSON.stringify(exact))).toBe(UI_CARD_MAX_BYTES); + expect(() => validate(exact)).not.toThrow(); + expect(() => validate({ ...exact, source: exact.source + ' ' })).toThrow('32 KB'); + expect(() => validate(card(prefix + '界'.repeat(12000) + suffix))).toThrow('32 KB'); + }); +}); diff --git a/backend/config/uiCardCatalogue.json b/backend/config/uiCardCatalogue.json new file mode 100644 index 0000000000..12a014c860 --- /dev/null +++ b/backend/config/uiCardCatalogue.json @@ -0,0 +1,10 @@ +{ + "$defs": { + "Text": { + "type": "object", + "properties": { "text": { "type": "string" } }, + "required": ["text"] + } + }, + "properties": { "Text": { "$ref": "#/$defs/Text" } } +} diff --git a/backend/package-lock.json b/backend/package-lock.json index f8579f2887..e0a33812f8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "@google/generative-ai": "^0.2.1", "@kubernetes/client-node": "^0.21.0", "@modelcontextprotocol/sdk": "^1.30.0", + "@openuidev/lang-core": "1.0.1", "@sendgrid/mail": "^7.7.0", "@sentry/node": "^10.65.0", "@socket.io/redis-adapter": "^8.3.0", @@ -2318,6 +2319,40 @@ "node": ">=14" } }, + "node_modules/@openuidev/lang-core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@openuidev/lang-core/-/lang-core-1.0.1.tgz", + "integrity": "sha512-4vBa7FRM6C4v4aCvBAmAtTgFWiEQiNTazG9h4Xy9fC6P/p6+9kZu4RdTyLToBEvdxrYn/gDy/PakEH8PU3lUcw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": ">=1.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@openuidev/lang-core/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -14866,6 +14901,21 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==" }, + "@openuidev/lang-core": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@openuidev/lang-core/-/lang-core-1.0.1.tgz", + "integrity": "sha512-4vBa7FRM6C4v4aCvBAmAtTgFWiEQiNTazG9h4Xy9fC6P/p6+9kZu4RdTyLToBEvdxrYn/gDy/PakEH8PU3lUcw==", + "requires": { + "ci-info": "^4.4.0" + }, + "dependencies": { + "ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==" + } + } + }, "@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", diff --git a/backend/package.json b/backend/package.json index 84e0f900cb..e2f772e088 100644 --- a/backend/package.json +++ b/backend/package.json @@ -41,6 +41,7 @@ "@google/generative-ai": "^0.2.1", "@kubernetes/client-node": "^0.21.0", "@modelcontextprotocol/sdk": "^1.30.0", + "@openuidev/lang-core": "1.0.1", "@sendgrid/mail": "^7.7.0", "@sentry/node": "^10.65.0", "@socket.io/redis-adapter": "^8.3.0", diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 446f1b4df4..129a7c8151 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -1,3 +1,4 @@ +import { validateAgentCardPayload } from '../services/uiCardValidation'; export {}; // TASK-099: the refusal classes a driver may name on release. The enum is the @@ -1572,7 +1573,9 @@ router.post( return res.status(403).json({ message: 'Bot not installed in this pod' }); } - const { content, metadata, messageType } = req.body || {}; + const { content, metadata, messageType, payload } = req.body || {}; + validateAgentCardPayload(messageType, payload, payload !== undefined + ? AgentMessageService.sanitizeAgentContent(content) : content); const wouldPost = messageType === 'card' || Boolean(AgentMessageService.sanitizeAgentContent(content)); if (wouldPost && req.body?.continueAnyway !== true) { const hold = await AgentSendFreshness.unseenMessages(user._id, podId); @@ -1586,6 +1589,7 @@ router.post( content, metadata, messageType, + ...(payload !== undefined ? { payload } : {}), installationConfig: installation.config || null, }); @@ -2263,6 +2267,9 @@ router.post('/pods/:podId/messages', phase4RateLimit, agentRuntimeAuth, async (r return res.status(403).json({ message: 'Agent token not authorized for this pod' }); } + validateAgentCardPayload(req.body?.messageType, req.body?.payload, req.body?.payload !== undefined + ? AgentMessageService.sanitizeAgentContent(req.body?.content) : req.body?.content); + // A total-match silence or runtime artifact is not a post. Let the // service suppress it without making the agent read a conversation first. const wouldPost = req.body?.messageType === 'card' @@ -2277,7 +2284,7 @@ router.post('/pods/:podId/messages', phase4RateLimit, agentRuntimeAuth, async (r } const { - content, metadata, messageType, replyToMessageId, threadRootId, + content, metadata, messageType, replyToMessageId, threadRootId, payload, } = req.body || {}; const result = await AgentMessageService.postMessage({ agentName: installation.agentName, @@ -2287,6 +2294,7 @@ router.post('/pods/:podId/messages', phase4RateLimit, agentRuntimeAuth, async (r content, metadata, messageType, + ...(payload !== undefined ? { payload } : {}), replyToMessageId: replyToMessageId || null, // In-thread post WITHOUT addressing (constraint 5, Sam's independence // rule): the thread is ambient membership, the reply edge is a ping. diff --git a/backend/services/uiCardValidation.ts b/backend/services/uiCardValidation.ts new file mode 100644 index 0000000000..32ffc542cd --- /dev/null +++ b/backend/services/uiCardValidation.ts @@ -0,0 +1,115 @@ +import { + createParser, parseExpression, split, tokenize, walkAST, +} from '@openuidev/lang-core'; +import type { LibraryJSONSchema } from '@openuidev/lang-core'; +import catalogue from '../config/uiCardCatalogue.json'; + +export const UI_CARD_MAX_BYTES = 32 * 1024; +const schema = catalogue as LibraryJSONSchema; +const parser = createParser(schema); +const componentNames = new Set(Object.keys(schema.properties || {})); +const bracketTokens = tokenize('()[]{}'); +const closingTokens = new Map([ + [bracketTokens[0].t, bracketTokens[1].t], + [bracketTokens[2].t, bracketTokens[3].t], + [bracketTokens[4].t, bracketTokens[5].t], +]); +const openParenToken = bracketTokens[0].t; +const allowedCall = (name: string) => componentNames.has(name) || ['Action', 'OpenUrl'].includes(name); + +export class UICardValidationError extends Error { + readonly statusCode = 400; + + readonly code = 'invalid_ui_card'; +} + +function refuse(message: string): never { + throw new UICardValidationError(message); +} + +/** Validate untrusted CAP input, never privileged kernel-composed payloads. */ +export function validateAgentCardPayload( + messageType: unknown, + payload: unknown, + content: unknown, +): void { + if (payload === undefined && messageType !== 'card') return; + if (messageType !== 'card') refuse('UI payload requires messageType card'); + if (typeof content !== 'string' || !content.trim()) refuse('UI card requires a content text fallback'); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) refuse('UI card payload must be an object'); + const envelope = payload as Record; + if (envelope.kind !== 'ui' || envelope.v !== 1 || envelope.lang !== 'openui@1' + || typeof envelope.source !== 'string' || !envelope.source.trim() + || Object.keys(envelope).some((key) => !['kind', 'v', 'lang', 'source'].includes(key))) { + refuse('Expected {kind:"ui",v:1,lang:"openui@1",source}'); + } + if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > UI_CARD_MAX_BYTES) { + refuse('UI card payload exceeds 32 KB'); + } + try { + const source = envelope.source as string; + // The streaming parser intentionally repairs incomplete input. Stored cards + // must be complete. Inspect ALL statements, including unreachable ones: + // materializing only root misses a dormant Query or an unknown component. + const tokens = tokenize(source); + // Also inspect tokens that the forgiving parser would discard after an + // expression. Quoted prose has string tokens and is never treated as code. + const brackets: number[] = []; + tokens.forEach((token, index) => { + const closing = closingTokens.get(token.t); + if (closing !== undefined) { + brackets.push(closing); + if (brackets.length > 64) refuse('UI card exceeds 64 nesting levels'); + } else if ([...closingTokens.values()].includes(token.t) && brackets.pop() !== token.t) { + refuse('UI card contains unmatched brackets'); + } + if (typeof token.v === 'string' && tokens[index + 1]?.t === openParenToken + && parseExpression([token]).k !== 'Str' && !allowedCall(token.v)) { + refuse(`UI card call is not allowed: ${token.v}`); + } + }); + if (brackets.length) refuse('UI card contains unmatched brackets'); + const statements = split(tokens); + if (statements.length > 256) refuse('UI card exceeds 256 statements'); + const ids = new Set(); + statements.forEach((statement) => { + if (ids.has(statement.id) || statement.id.startsWith('$')) { + refuse('UI card contains duplicate or reactive statements'); + } + ids.add(statement.id); + walkAST(parseExpression(statement.tokens), (node) => { + if (node.k === 'Comp') { + if (!allowedCall(node.name)) { + refuse(`UI card call is not allowed: ${node.name}`); + } + if (node.fn) refuse('UI card functions are not allowed'); + if (node.name === 'OpenUrl') { + const [url] = node.args; + if (node.args.length !== 1 || url?.k !== 'Str') refuse('OpenUrl requires a literal https URL'); + const parsed = new URL(url.v); + if (parsed.protocol !== 'https:' || parsed.username || parsed.password) { + refuse('OpenUrl requires a literal https URL'); + } + } + } else if (node.k === 'Ref' && ['Query', 'Mutation', 'Run'].includes(node.n)) { + refuse(`UI card capability is not allowed: ${node.n}`); + } else if (node.k === 'Obj') { + if (node.entries.some(([key]) => ['__proto__', 'prototype', 'constructor'].includes(key))) { + refuse('UI card prototype keys are not allowed'); + } + } else if (!['Str', 'Num', 'Bool', 'Null', 'Arr', 'Ref'].includes(node.k)) { + refuse('UI card reactive expressions are not allowed'); + } + }); + }); + const result = parser.parse(source); + if (!result.root || result.meta.incomplete || result.meta.unresolved.length + || result.meta.errors.length || result.queryStatements.length || result.mutationStatements.length + || Object.keys(result.stateDeclarations).length) { + refuse('UI card source is incomplete or invalid for the Commonly catalogue'); + } + } catch (error) { + if (error instanceof UICardValidationError) throw error; + refuse('UI card source could not be parsed'); + } +} diff --git a/cli/__tests__/pod-send.test.mjs b/cli/__tests__/pod-send.test.mjs new file mode 100644 index 0000000000..599234a31b --- /dev/null +++ b/cli/__tests__/pod-send.test.mjs @@ -0,0 +1,47 @@ +import { jest } from '@jest/globals'; +import { Command } from 'commander'; + +const post = jest.fn(); +await jest.unstable_mockModule('../src/lib/api.js', () => ({ createClient: () => ({ post }) })); +await jest.unstable_mockModule('../src/lib/config.js', () => ({ + getToken: () => 'own-runtime-fixture', resolveInstanceUrl: () => 'https://api.example.org', +})); +const { registerPod } = await import('../src/commands/pod.js'); +const run = async (...args) => { + const program = new Command(); + registerPod(program); + await program.parseAsync(['node', 'commonly', 'pod', 'send', 'pod-1', 'text fallback', ...args]); +}; +beforeEach(() => { + post.mockReset().mockResolvedValue({ id: 'm1' }); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(process, 'exit').mockImplementation(() => {}); +}); +afterEach(() => jest.restoreAllMocks()); + +test('sends UI JSON on the real pod command with the content fallback', async () => { + const payload = { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hi")' }; + await run('--payload', JSON.stringify(payload)); + expect(post).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith('/api/agents/runtime/pods/pod-1/messages', { + content: 'text fallback', messageType: 'card', payload, + }); +}); +test.each([400, 401, 403, 409, 500])('never retries a card refusal (%s) through the human writer', async (status) => { + post.mockRejectedValueOnce(Object.assign(new Error('refused'), { status })); + await run('--payload', '{"kind":"approval"}'); + expect(post).toHaveBeenCalledTimes(1); + expect(process.exit).toHaveBeenCalledWith(1); +}); +test('malformed JSON sends no requests', async () => { + await run('--payload', '{broken'); + expect(post).not.toHaveBeenCalled(); + expect(process.exit).toHaveBeenCalledWith(1); +}); +test('ordinary text retains the existing login fallback', async () => { + post.mockRejectedValueOnce(new Error('agent auth required')); + await run(); + expect(post).toHaveBeenCalledTimes(2); + expect(post).toHaveBeenLastCalledWith('/api/messages/pod-1', { content: 'text fallback' }); +}); diff --git a/cli/src/commands/pod.js b/cli/src/commands/pod.js index 064c7c1923..1716812254 100644 --- a/cli/src/commands/pod.js +++ b/cli/src/commands/pod.js @@ -55,6 +55,7 @@ Commonly instance (see: commonly login --help). pod .command('send ') .description('Post a message to a pod') + .option('--payload ', 'UI card JSON envelope; the message remains the required text fallback') .option('--instance ', 'Target Commonly instance') .action(async (podId, message, opts) => { const token = getToken(opts.instance); @@ -63,12 +64,18 @@ Commonly instance (see: commonly login --help). const client = createClient({ instance: resolveInstanceUrl(opts.instance), token }); try { - // Try agent runtime endpoint first (agent token), fall back to user endpoint - const result = await client.post(`/api/agents/runtime/pods/${podId}/messages`, { - content: message, - }).catch(() => client.post(`/api/messages/${podId}`, { - content: message, - })); + const body = { content: message }; + if (opts.payload !== undefined) { + body.payload = JSON.parse(opts.payload); + body.messageType = 'card'; + } + // UI cards use the validated agent endpoint only. A validation or + // authorization refusal must never retry through the human writer. + const result = await client.post(`/api/agents/runtime/pods/${podId}/messages`, body) + .catch((error) => { + if (opts.payload !== undefined) throw error; + return client.post(`/api/messages/${podId}`, body); + }); console.log(`Sent (${result._id || result.id || 'ok'})`); } catch (err) { diff --git a/commonly-mcp/__tests__/tools.test.mjs b/commonly-mcp/__tests__/tools.test.mjs index ff5c63cc13..03d2caf739 100644 --- a/commonly-mcp/__tests__/tools.test.mjs +++ b/commonly-mcp/__tests__/tools.test.mjs @@ -74,6 +74,15 @@ const okResponse = (body) => ({ afterEach(() => { delete global.fetch; }); describe('commonly_post_message', () => { + it('forwards UI card payload and explicit type alongside the text fallback', async () => { + const fetchSpy = installFetch(async () => okResponse({ id: 'card1' })); + const payload = { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; + await byName.commonly_post_message.call({ podId: 'p', content: 'fallback', messageType: 'card', payload }); + expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ content: 'fallback', messageType: 'card', payload }); + expect(byName.commonly_post_message.inputSchema.properties.messageType.enum).toContain('card'); + expect(byName.commonly_post_message.inputSchema.properties.payload.type).toBe('object'); + }); + it('POSTs to the right path with body', async () => { const fetchSpy = installFetch(async () => okResponse({ id: 'm1' })); const result = await byName.commonly_post_message.call({ diff --git a/commonly-mcp/src/tools.js b/commonly-mcp/src/tools.js index 04ec963b2c..e9a743f8fc 100644 --- a/commonly-mcp/src/tools.js +++ b/commonly-mcp/src/tools.js @@ -184,11 +184,13 @@ export const buildTools = (config) => { threadRootId: STRING, continueAnyway: { type: 'boolean' }, metadata: { type: 'object', additionalProperties: true }, + messageType: { type: 'string', enum: ['text', 'card'] }, + payload: { type: 'object', description: 'UI card only: {kind:"ui",v:1,lang:"openui@1",source}. Requires messageType card and content text fallback; validated by the server.', additionalProperties: true }, }, ['podId', 'content']), - call: wrap(async ({ podId, content, replyToMessageId, threadRootId, metadata, continueAnyway }) => request(config, { + call: wrap(async ({ podId, content, replyToMessageId, threadRootId, metadata, continueAnyway, messageType, payload }) => request(config, { method: 'POST', path: `/api/agents/runtime/pods/${encodeURIComponent(podId)}/messages`, - body: { content, replyToMessageId, threadRootId, metadata, continueAnyway }, + body: { content, replyToMessageId, threadRootId, metadata, continueAnyway, messageType, payload }, })), }, { diff --git a/docs/api/agent-ui-cards.md b/docs/api/agent-ui-cards.md new file mode 100644 index 0000000000..3ebd4b09e2 --- /dev/null +++ b/docs/api/agent-ui-cards.md @@ -0,0 +1,62 @@ +# Agent-authored UI cards + +The agent runtime message writer accepts an additive UI card envelope. Kernel +approval, decision and handoff payloads remain server-composed; agent requests +cannot write their kinds through this surface. + +```json +{ + "content": "A readable text fallback for clients without the UI renderer.", + "messageType": "card", + "payload": { + "kind": "ui", + "v": 1, + "lang": "openui@1", + "source": "root = Text(\"Hello\")" + } +} +``` + +Send to `POST /api/agents/runtime/pods/:podId/messages`, using the existing +pod-authorized runtime token and freshness rules. The bot API-token message +route uses the same validator. A payload on a non-card message is refused. + +`commonly_post_message` accepts `messageType` and `payload` alongside its required +`content`. The CLI takes a JSON envelope: + +```sh +commonly pod send 'A readable text fallback.' --payload '{"kind":"ui","v":1,"lang":"openui@1","source":"root = Text(\"Hello\")"}' +``` + +Cards are sent only through the validated agent writer. Unlike the existing +plain-text login fallback, a refused card is never retried on the human message +endpoint. Server refusals return HTTP 400 with `code: invalid_ui_card`; correcting +the source is required before retrying. + +## Write boundary + +- Envelope version is exactly `v: 1`, language is exactly `openui@1`. Additional + client fields (including a claimed `runId`) are refused. +- The whole JSON envelope, serialized in UTF-8, must be at most 32,768 bytes. +- Content must contain a nonempty text fallback after agent content sanitization. +- `@openuidev/lang-core` 1.0.1 parses the source against the Commonly catalogue. + It must produce a complete root without unresolved references or prop errors. +- Every statement is inspected, including unreachable statements. Unknown + component calls, Query, Mutation, Run, custom functions, reactive expressions, + duplicate definitions, prototype keys, over 256 statements and over 64 nested + bracket levels are refused. +- Only catalogue components and the `Action` / literal HTTPS `OpenUrl` constructs + are permitted. The renderer owns the HTTPS hostname allowlist and gets no tool + provider. Server parsing never executes a tool or an action. + +The canonical serializable catalogue is `backend/config/uiCardCatalogue.json`. +TASK-498 generates the frontend copy and checks drift because the backend and +frontend images have separate Docker build contexts. Names and positional prop +order are part of the stored `openui@1` contract; incompatible changes need a +new envelope/language version and reader compatibility. + +The parser is MIT licensed. Installation telemetry was disabled for the local +install (`npm install --ignore-scripts`); its runtime telemetry is opt-in and is +not enabled by this feature. Operators can set `OPENUI_TELEMETRY_DISABLED=1` for +all install/runtime telemetry. The backend image sets this opt-out before +installing dependencies. No cloud parser or devtools are used. From fa5bf26661c5458b05ff4a16119be7a4f2c34990 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:46:02 -0700 Subject: [PATCH 02/10] test: refuse suppressed UI fallbacks and object-spoofed roots Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- .../unit/routes/agentsRuntime.post-message.config.test.js | 7 +++++++ backend/__tests__/unit/services/uiCardValidation.test.js | 1 + 2 files changed, 8 insertions(+) diff --git a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js index b3c722624a..e4fc57231c 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js @@ -246,6 +246,13 @@ describe('agent card route boundary', () => { expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); expect(findNonMemberMentionNotices).not.toHaveBeenCalled(); }); + it('refuses a fallback that the existing sanitizer suppresses', async () => { + AgentMessageService.sanitizeAgentContent.mockReturnValueOnce(''); + const res = response(); + await handler(req({ messageType: 'card', payload, content: 'NO_REPLY' }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); + }); it('keeps the pod authorization check ahead of parsing', async () => { const res = response(); await handler({ ...req({ payload }), params: { podId: 'other' } }, res); diff --git a/backend/__tests__/unit/services/uiCardValidation.test.js b/backend/__tests__/unit/services/uiCardValidation.test.js index 27cb541c19..be8e3f6264 100644 --- a/backend/__tests__/unit/services/uiCardValidation.test.js +++ b/backend/__tests__/unit/services/uiCardValidation.test.js @@ -44,6 +44,7 @@ describe('untrusted UI card write validation', () => { 'root = Text(missing)', 'root = Text("hi")\nroot = Text("replacement")', 'root = Text({__proto__: {polluted: true}})', + 'root = {type: "element", typeName: "Query", props: {tool: "secret"}}', 'no root here', ])('refuses unsafe, unknown, or incomplete source: %s', (source) => { expect(() => validate(card(source))).toThrow(expect.objectContaining({ statusCode: 400 })); From 9c97517f4464cc164b57deca7209b88223af934f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:50:53 -0700 Subject: [PATCH 03/10] feat: validate UI cards from the native post tool Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- .../nativeRuntimeService.byoHandoff.test.js | 42 ++++++++++++++++++- backend/services/nativeRuntimeService.ts | 15 ++++++- docs/api/agent-ui-cards.md | 4 +- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js index 57e20660ca..f1297e6474 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js @@ -16,7 +16,10 @@ jest.mock('../../../models/User', () => ({ findById: (...args) => mockUserFindById(...args), })); jest.mock('../../../services/agentTypingService', () => ({ emitAgentTypingStart: jest.fn(), emitAgentTypingStop: jest.fn() })); -jest.mock('../../../services/agentMessageService', () => ({ postMessage: jest.fn().mockResolvedValue({ success: true }) })); +jest.mock('../../../services/agentMessageService', () => ({ + postMessage: jest.fn().mockResolvedValue({ success: true }), + sanitizeAgentContent: jest.fn((content) => content), +})); jest.mock('../../../services/messageClaimService', () => ({ claim: jest.fn().mockResolvedValue({ claimed: true, expiresAt: new Date() }), release: jest.fn().mockResolvedValue({ released: true }), @@ -166,6 +169,43 @@ describe('commonly_offer_local_agent', () => { expect(posted.noOutboundRelay).toBe(true); }); + test('native post tool forwards a validated UI card and offers the payload schema', async () => { + const payload = { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("native card")' }; + axios.post.mockResolvedValueOnce(callsTool('commonly_post_message', { + content: 'Text fallback', messageType: 'card', payload, + })).mockResolvedValueOnce(says('NO_REPLY')); + await runAgent({ ...install, config: { ...install.config, tools: ['commonly_post_message'] } }, ask); + expect(AgentMessageService.postMessage).toHaveBeenCalledTimes(1); + expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: 'Text fallback', messageType: 'card', payload, + })); + const tool = axios.post.mock.calls[0][1].tools.find((t) => t.function.name === 'commonly_post_message'); + expect(tool.function.parameters.properties.payload.type).toBe('object'); + }); + test.each([ + { kind: 'approval', v: 1, lang: 'openui@1', source: 'root = Text("forged")' }, + { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hi")\nunused = Query("secret", {})' }, + { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hi")', runId: 'other-run' }, + ])('native post tool refuses unsafe payloads before the writer %#', async (payload) => { + axios.post.mockResolvedValueOnce(callsTool('commonly_post_message', { + content: 'Text fallback', messageType: 'card', payload, + })).mockResolvedValueOnce(says('')); + await runAgent({ ...install, config: { ...install.config, tools: ['commonly_post_message'] } }, ask); + expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); + const toolResult = axios.post.mock.calls[1][1].messages.find((m) => m.role === 'tool'); + expect(JSON.parse(toolResult.content).ok).toBe(false); + }); + + test('native post tool refuses an undeclared message type before the writer', async () => { + axios.post.mockResolvedValueOnce(callsTool('commonly_post_message', { + content: 'Text fallback', messageType: 'system', + })).mockResolvedValueOnce(says('')); + await runAgent({ ...install, config: { ...install.config, tools: ['commonly_post_message'] } }, ask); + expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); + const toolResult = axios.post.mock.calls[1][1].messages.find((m) => m.role === 'tool'); + expect(JSON.parse(toolResult.content).ok).toBe(false); + }); + test('does not let a client post mark itself as kernel-authored', async () => { axios.post .mockResolvedValueOnce(callsTool('commonly_post_message', { diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 5a05762b8b..725d174829 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -26,6 +26,7 @@ @typescript-eslint/no-require-imports */ import { Types } from 'mongoose'; +import { validateAgentCardPayload } from './uiCardValidation'; import { isTaskPriority, TASK_PRIORITY_ORDER } from '../utils/taskPriority'; import axios, { AxiosError } from 'axios'; import { @@ -277,7 +278,9 @@ export const TOOLS = [ parameters: { type: 'object', properties: { - content: { type: 'string', description: 'Markdown-supported message content' }, + content: { type: 'string', description: 'Markdown-supported message content and required UI text fallback' }, + messageType: { type: 'string', enum: ['text', 'card'] }, + payload: { type: 'object', description: 'Optional {kind:"ui",v:1,lang:"openui@1",source}. Requires messageType card; validated locally before writing.' }, }, required: ['content'], }, @@ -782,12 +785,20 @@ export async function dispatchTool( return { content: { ok: false, error: 'content is required' }, error: 'missing_content' }; } const AgentMessageService = require('./agentMessageService'); + const messageType = args.messageType ?? 'text'; + if (messageType !== 'text' && messageType !== 'card') { + return { content: { ok: false, error: 'Unsupported native messageType' }, error: 'invalid_message_type' }; + } + validateAgentCardPayload(messageType, args.payload, args.payload !== undefined + ? (typeof args.content === 'string' ? AgentMessageService.sanitizeAgentContent(args.content) : args.content) + : content); const result = await AgentMessageService.postMessage({ agentName: ctx.agentName, instanceId: ctx.instanceId, podId: ctx.podId, content, - messageType: 'text', + messageType, + ...(args.payload !== undefined ? { payload: args.payload } : {}), displayName: ctx.displayName, installationConfig: ctx.installationConfig, metadata: { source: 'native-runtime' }, diff --git a/docs/api/agent-ui-cards.md b/docs/api/agent-ui-cards.md index 3ebd4b09e2..a0fcf39868 100644 --- a/docs/api/agent-ui-cards.md +++ b/docs/api/agent-ui-cards.md @@ -22,7 +22,9 @@ pod-authorized runtime token and freshness rules. The bot API-token message route uses the same validator. A payload on a non-card message is refused. `commonly_post_message` accepts `messageType` and `payload` alongside its required -`content`. The CLI takes a JSON envelope: +`content`. The native hosted post tool uses the same schema and validator +before its internal service write. Its run provenance is supplied by the +trusted runtime, separately from model arguments. The CLI takes a JSON envelope: ```sh commonly pod send 'A readable text fallback.' --payload '{"kind":"ui","v":1,"lang":"openui@1","source":"root = Text(\"Hello\")"}' From 3ad63989929354e64ed6ceba73c33d22cc956270 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:57:22 -0700 Subject: [PATCH 04/10] feat: enforce the shared UI card catalogue and bounds Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- .../unit/services/uiCardValidation.test.js | 46 +- backend/config/uiCardCatalogue.json | 423 +++++++++++++++++- backend/package-lock.json | 245 ++++++---- backend/package.json | 1 + backend/services/uiCardValidation.ts | 63 ++- docs/api/agent-ui-cards.md | 17 + docs/development/agent-experience-audit.md | 10 + 7 files changed, 701 insertions(+), 104 deletions(-) diff --git a/backend/__tests__/unit/services/uiCardValidation.test.js b/backend/__tests__/unit/services/uiCardValidation.test.js index be8e3f6264..32ff4a2807 100644 --- a/backend/__tests__/unit/services/uiCardValidation.test.js +++ b/backend/__tests__/unit/services/uiCardValidation.test.js @@ -49,14 +49,56 @@ describe('untrusted UI card write validation', () => { ])('refuses unsafe, unknown, or incomplete source: %s', (source) => { expect(() => validate(card(source))).toThrow(expect.objectContaining({ statusCode: 400 })); }); + it.each([ + 'root = Card("Plan", [Text("Do it"), Steps([Step("First", "active"), Step("Second", "pending")])])', + 'root = DiffSummary([File("src/main.ts", 4, 2)])', + 'root = TaskStatus("TASK-497", "Read from the board")', + 'root = KeyValue([Pair("Checks", 42), Pair("Ready", true)])', + 'root = Table([Col("Name"), Col("Count", "number")], [["tests", 42]])', + 'root = Badge("Ready", "done")', + 'root = Link("Docs", "https://commonly.me/docs", "text")', + 'root = RunTrace(3)', + 'root = List(["one", "two"], true)', + 'root = Text(["Read ", Link("the docs", "https://commonly.me/docs", "text")])', + ])('accepts the artboard catalogue: %s', (source) => { + expect(() => validate(card(source))).not.toThrow(); + }); + it.each([ + 'root = Text(' + JSON.stringify('a'.repeat(601)) + ')', + 'root = Card(' + JSON.stringify('a'.repeat(81)) + ', [Text("hi")])', + 'root = Card("Empty", [])', + 'root = List([])', + 'root = Steps([Step("only", "done")])', + 'root = Steps([Step("one", "active"), Step("two", "active")])', + 'root = Steps([Step("one", "unknown"), Step("two", "pending")])', + 'root = DiffSummary([File("file", -1, 0)])', + 'root = RunTrace(0)', + 'root = RunTrace(6)', + 'root = RunTrace(1.5)', + 'root = Badge("Ready", "cobalt")', + 'root = TaskStatus("TASK-1", "note", "done")', + 'root = Table([Col("Name")], [["one", "extra"]])', + 'root = Text(["prefix", Link("button", "https://commonly.me", "button")])', + 'root = Link("unsafe", "http://commonly.me", "text")', + 'root = Link("unsafe", "https://user:password@commonly.me", "text")', + 'root = Link("unsafe", "javascript:alert(1)", "text")', + 'root = Card("Two primary", [Link("one", "https://commonly.me", "primary"), Link("two", "https://commonly.me", "primary")])', + 'root = Card("Three buttons", [Link("one", "https://commonly.me", "button"), Link("two", "https://commonly.me", "button"), Link("three", "https://commonly.me", "button")])', + 'root = Step("not a visual root", "done")', + 'root = Card("wrong helper", [Pair("one", "two")])', + 'root = Text([' + JSON.stringify('a'.repeat(400)) + ', ' + JSON.stringify('b'.repeat(201)) + '])', + ])('refuses catalogue limits and invalid helper placement: %s', (source) => { + expect(() => validate(card(source))).toThrow(expect.objectContaining({ statusCode: 400 })); + }); it('refuses deeply nested input before recursive parsing', () => { expect(() => validate(card('root = Text(' + '['.repeat(1000) + '0' + ']'.repeat(1000) + ')'))).toThrow('nesting'); }); it('limits serialized payload bytes, including multibyte Unicode and exact boundary', () => { const prefix = 'root = Text("'; const suffix = '")'; - const overhead = Buffer.byteLength(JSON.stringify(card(prefix + suffix))); - const exact = card(prefix + 'a'.repeat(UI_CARD_MAX_BYTES - overhead) + suffix); + const exact = card(); + const overhead = Buffer.byteLength(JSON.stringify(exact)); + exact.source += ' '.repeat(UI_CARD_MAX_BYTES - overhead); expect(Buffer.byteLength(JSON.stringify(exact))).toBe(UI_CARD_MAX_BYTES); expect(() => validate(exact)).not.toThrow(); expect(() => validate({ ...exact, source: exact.source + ' ' })).toThrow('32 KB'); diff --git a/backend/config/uiCardCatalogue.json b/backend/config/uiCardCatalogue.json index 12a014c860..c5d2b261fa 100644 --- a/backend/config/uiCardCatalogue.json +++ b/backend/config/uiCardCatalogue.json @@ -1,10 +1,427 @@ { "$defs": { + "Card": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 80 + }, + "children": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Card" + }, + { + "$ref": "#/$defs/Text" + }, + { + "$ref": "#/$defs/List" + }, + { + "$ref": "#/$defs/Steps" + }, + { + "$ref": "#/$defs/DiffSummary" + }, + { + "$ref": "#/$defs/TaskStatus" + }, + { + "$ref": "#/$defs/KeyValue" + }, + { + "$ref": "#/$defs/Table" + }, + { + "$ref": "#/$defs/Badge" + }, + { + "$ref": "#/$defs/Link" + }, + { + "$ref": "#/$defs/RunTrace" + } + ] + }, + "minItems": 1, + "maxItems": 6 + }, + "meta": { + "type": "string", + "maxLength": 32 + }, + "summary": { + "type": "string", + "maxLength": 200 + } + }, + "required": [ + "title", + "children" + ], + "additionalProperties": false + }, "Text": { "type": "object", - "properties": { "text": { "type": "string" } }, - "required": ["text"] + "properties": { + "body": { + "anyOf": [ + { + "type": "string", + "maxLength": 600 + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "maxLength": 600 + }, + { + "$ref": "#/$defs/Link" + } + ] + } + } + ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false + }, + "List": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string", + "maxLength": 120 + }, + "minItems": 1, + "maxItems": 8 + }, + "ordered": { + "type": "boolean", + "default": false + } + }, + "required": [ + "items" + ], + "additionalProperties": false + }, + "Steps": { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/Step" + }, + "minItems": 2, + "maxItems": 8 + } + }, + "required": [ + "steps" + ], + "additionalProperties": false + }, + "Step": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 60 + }, + "state": { + "type": "string", + "enum": [ + "pending", + "active", + "done", + "failed", + "skipped" + ] + }, + "detail": { + "type": "string", + "maxLength": 120 + } + }, + "required": [ + "label", + "state" + ], + "additionalProperties": false + }, + "DiffSummary": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/$defs/File" + } + } + }, + "required": [ + "files" + ], + "additionalProperties": false + }, + "File": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "added": { + "type": "integer", + "minimum": 0 + }, + "removed": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "path", + "added", + "removed" + ], + "additionalProperties": false + }, + "TaskStatus": { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + }, + "KeyValue": { + "type": "object", + "properties": { + "pairs": { + "type": "array", + "items": { + "$ref": "#/$defs/Pair" + } + } + }, + "required": [ + "pairs" + ], + "additionalProperties": false + }, + "Pair": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "key", + "value" + ], + "additionalProperties": false + }, + "Table": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "$ref": "#/$defs/Col" + }, + "minItems": 1 + }, + "rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + } + }, + "required": [ + "columns", + "rows" + ], + "additionalProperties": false + }, + "Col": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "label" + ], + "additionalProperties": false + }, + "Badge": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 24 + }, + "tone": { + "type": "string", + "enum": [ + "neutral", + "ink", + "done", + "failed", + "pending" + ] + } + }, + "required": [ + "label", + "tone" + ], + "additionalProperties": false + }, + "Link": { + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 40 + }, + "url": { + "type": "string" + }, + "as": { + "type": "string", + "enum": [ + "text", + "button", + "primary" + ] + } + }, + "required": [ + "label", + "url", + "as" + ], + "additionalProperties": false + }, + "RunTrace": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5 + } + }, + "required": [ + "limit" + ], + "additionalProperties": false } }, - "properties": { "Text": { "$ref": "#/$defs/Text" } } + "properties": { + "Card": { + "$ref": "#/$defs/Card" + }, + "Text": { + "$ref": "#/$defs/Text" + }, + "List": { + "$ref": "#/$defs/List" + }, + "Steps": { + "$ref": "#/$defs/Steps" + }, + "Step": { + "$ref": "#/$defs/Step" + }, + "DiffSummary": { + "$ref": "#/$defs/DiffSummary" + }, + "File": { + "$ref": "#/$defs/File" + }, + "TaskStatus": { + "$ref": "#/$defs/TaskStatus" + }, + "KeyValue": { + "$ref": "#/$defs/KeyValue" + }, + "Pair": { + "$ref": "#/$defs/Pair" + }, + "Table": { + "$ref": "#/$defs/Table" + }, + "Col": { + "$ref": "#/$defs/Col" + }, + "Badge": { + "$ref": "#/$defs/Badge" + }, + "Link": { + "$ref": "#/$defs/Link" + }, + "RunTrace": { + "$ref": "#/$defs/RunTrace" + } + } } diff --git a/backend/package-lock.json b/backend/package-lock.json index e0a33812f8..52bc11cdef 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "@sentry/node": "^10.65.0", "@socket.io/redis-adapter": "^8.3.0", "adm-zip": "^0.6.1", + "ajv": "8.20.0", "axios": "^1.20.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", @@ -905,6 +906,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@eslint/eslintrc/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -966,6 +984,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/eslintrc/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1771,22 +1796,6 @@ "node": ">= 0.6" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1981,12 +1990,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -3871,15 +3874,15 @@ } }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -3903,28 +3906,6 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -6022,6 +6003,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -6123,6 +6121,13 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7287,6 +7292,28 @@ "node": ">=6" } }, + "node_modules/har-validator/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/har-validator/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8883,9 +8910,10 @@ "license": "(AFL-2.1 OR BSD-3-Clause)" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/json-schema-typed": { "version": "8.0.2", @@ -12947,6 +12975,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -13956,6 +13985,18 @@ "strip-json-comments": "^3.1.1" }, "dependencies": { + "ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -13989,6 +14030,12 @@ "argparse": "^2.0.1" } }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -14561,17 +14608,6 @@ "negotiator": "^1.0.0" } }, - "ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "requires": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "3.1.8", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - } - }, "body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -14691,11 +14727,6 @@ "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==" }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, "media-typer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -16022,14 +16053,14 @@ } }, "ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "3.1.8", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" } }, "ajv-formats": { @@ -16038,24 +16069,6 @@ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "requires": { "ajv": "^8.0.0" - }, - "dependencies": { - "ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "requires": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "3.1.8", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - } - }, - "json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - } } }, "ansi-escapes": { @@ -17409,6 +17422,18 @@ "text-table": "^0.2.0" }, "dependencies": { + "ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -17467,6 +17492,12 @@ "argparse": "^2.0.1" } }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -18396,6 +18427,24 @@ "requires": { "ajv": "^6.12.3", "har-schema": "^2.0.0" + }, + "dependencies": { + "ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + } } }, "has-bigints": { @@ -19523,9 +19572,9 @@ "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" }, "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "json-schema-typed": { "version": "8.0.2", diff --git a/backend/package.json b/backend/package.json index e2f772e088..2797fd9a5c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -46,6 +46,7 @@ "@sentry/node": "^10.65.0", "@socket.io/redis-adapter": "^8.3.0", "adm-zip": "^0.6.1", + "ajv": "8.20.0", "axios": "^1.20.0", "bcryptjs": "^2.4.3", "cors": "^2.8.5", diff --git a/backend/services/uiCardValidation.ts b/backend/services/uiCardValidation.ts index 32ffc542cd..6566bb61e3 100644 --- a/backend/services/uiCardValidation.ts +++ b/backend/services/uiCardValidation.ts @@ -1,7 +1,8 @@ import { createParser, parseExpression, split, tokenize, walkAST, } from '@openuidev/lang-core'; -import type { LibraryJSONSchema } from '@openuidev/lang-core'; +import type { ElementNode, LibraryJSONSchema } from '@openuidev/lang-core'; +import Ajv from 'ajv'; import catalogue from '../config/uiCardCatalogue.json'; export const UI_CARD_MAX_BYTES = 32 * 1024; @@ -16,6 +17,64 @@ const closingTokens = new Map([ ]); const openParenToken = bracketTokens[0].t; const allowedCall = (name: string) => componentNames.has(name) || ['Action', 'OpenUrl'].includes(name); +const helpers = new Set(['Step', 'File', 'Pair', 'Col']); +// lang-core validates prop types but ignores JSON Schema bounds (maxLength, +// minItems, minimum, etc.). Validate its materialized tree as well. References +// name element wrappers so helper/child identity is checked, not only prop shape. +const validateTree = new Ajv({ strict: false }).compile({ + $defs: Object.fromEntries(Object.entries(catalogue.$defs).map(([name, props]) => [name, { + type: 'object', + properties: { + type: { const: 'element' }, typeName: { const: name }, props, + partial: { type: 'boolean' }, hasDynamicProps: { type: 'boolean' }, statementId: { type: 'string' }, + }, + required: ['type', 'typeName', 'props'], + additionalProperties: false, + }])), + anyOf: Object.keys(catalogue.properties).filter((name) => !helpers.has(name)) + .map((name) => ({ $ref: `#/$defs/${name}` })), +}); + +function validatePresentation(root: ElementNode): void { + let buttons = 0; + let primary = 0; + function visit(value: unknown): void { + if (Array.isArray(value)) { value.forEach(visit); return; } + if (!value || typeof value !== 'object') return; + const node = value as ElementNode; + if (node.type !== 'element') return; + const { props } = node; + if (node.typeName === 'Link') { + const url = new URL(props.url as string); + if (url.protocol !== 'https:' || url.username || url.password) refuse('Link requires a credential-free https URL'); + if (props.as !== 'text') buttons += 1; + if (props.as === 'primary') primary += 1; + } + if (node.typeName === 'Text' && Array.isArray(props.body)) { + let length = 0; + props.body.forEach((part: string | ElementNode) => { + if (typeof part === 'string') length += [...part].length; + else { + if (part.props.as !== 'text') refuse('Inline Text links must use text presentation'); + length += [...String(part.props.label)].length; + } + }); + if (length > 600) refuse('Text body exceeds 600 characters'); + } + if (node.typeName === 'Steps' + && (props.steps as ElementNode[]).filter((step) => step.props.state === 'active').length > 1) { + refuse('Steps permits at most one active step'); + } + if (node.typeName === 'Table' + && (props.rows as unknown[][]).some((row) => row.length !== (props.columns as unknown[]).length)) { + refuse('Table row width must match its columns'); + } + Object.values(props).forEach(visit); + } + visit(root); + if (buttons > 2 || primary > 1) refuse('UI card permits at most two buttons and one primary link'); +} + export class UICardValidationError extends Error { readonly statusCode = 400; @@ -108,6 +167,8 @@ export function validateAgentCardPayload( || Object.keys(result.stateDeclarations).length) { refuse('UI card source is incomplete or invalid for the Commonly catalogue'); } + if (!validateTree(result.root)) refuse('UI card props violate the Commonly catalogue'); + validatePresentation(result.root); } catch (error) { if (error instanceof UICardValidationError) throw error; refuse('UI card source could not be parsed'); diff --git a/docs/api/agent-ui-cards.md b/docs/api/agent-ui-cards.md index a0fcf39868..b9b6c2f20a 100644 --- a/docs/api/agent-ui-cards.md +++ b/docs/api/agent-ui-cards.md @@ -51,6 +51,23 @@ the source is required before retrying. are permitted. The renderer owns the HTTPS hostname allowlist and gets no tool provider. Server parsing never executes a tool or an action. +## Catalogue v1 + +Visual components: Card, Text, List, Steps, DiffSummary, TaskStatus, KeyValue, +Table, Badge, Link and RunTrace. Step, File, Pair and Col are positional helper +components, accepted only in their declared slots and never as visual roots. +There is no Stack component; Card children stack. + +Lang-core reports basic prop errors but does not enforce all JSON Schema bounds. +The write boundary also validates its materialized tree with Ajv 8.20.0 (MIT), +using the same catalogue's complete constraints. Examples proving the gap and +its guard are in `uiCardValidation.test.js`; see AX audit entry 85. + +Additional presentation checks enforce at most one active Step, at most two +button links and one primary link, text-only inline links and a total Text body +of 600 characters. Table row width must match its columns. Link destinations +must be credential-free HTTPS; the renderer further restricts hostnames. + The canonical serializable catalogue is `backend/config/uiCardCatalogue.json`. TASK-498 generates the frontend copy and checks drift because the backend and frontend images have separate Docker build contexts. Names and positional prop diff --git a/docs/development/agent-experience-audit.md b/docs/development/agent-experience-audit.md index 8096552c87..42ae800aff 100644 --- a/docs/development/agent-experience-audit.md +++ b/docs/development/agent-experience-audit.md @@ -4210,3 +4210,13 @@ Settings read one writable pod's `config.environment`, while the daemon selected **Repair:** Settings now uses the daemon's environment projection for model and effort, saves through that projection's source pod, and shows standalone CLI controls as locally set and read-only. Focused route, UI and adapter tests connect the saved effort to the spawn argv. **Lesson:** a displayed runtime setting must identify the source the runner reads. A server preference cannot attest a local process that does not consume it. + +## 85. A schema-aware UI parser did not enforce schema bounds (2026-10-25, tern) + +*Origin: TASK-497 / #2675. `@openuidev/lang-core` 1.0.1 accepts a library JSON Schema and reports prop validation errors, suggesting that the schema's limits are enforced.* + +Measured with `createParser(catalogue).parse(source)`: a `Text` body of 601 characters under `maxLength: 600`, and `RunTrace(7)` under `maximum: 5`, both produced a root with an empty `meta.errors` array. The parser checks basic prop types but does not apply every JSON Schema keyword. Parsing only the root also misses unknown component calls in unreachable statements. + +**Repair:** inspect every statement/call before accepting source, then validate the materialized element tree with a full JSON Schema validator. Element wrappers preserve component identity when following child/helper references. `backend/__tests__/unit/services/uiCardValidation.test.js` pins string/array/number limits, helper placement and cross-item rules as well as dormant unsafe calls. + +**Lesson:** accepting a schema as a parser input does not establish which schema constraints it enforces. Probe the actual boundary values before making the parser an authorization or resource-limit boundary. From 10031776cbd9d34583396a676b20a7cdccd595ab Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:59:22 -0700 Subject: [PATCH 05/10] fix: apply the RunTrace default from the artboard Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- backend/__tests__/unit/services/uiCardValidation.test.js | 1 + backend/config/uiCardCatalogue.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/unit/services/uiCardValidation.test.js b/backend/__tests__/unit/services/uiCardValidation.test.js index 32ff4a2807..31905385d3 100644 --- a/backend/__tests__/unit/services/uiCardValidation.test.js +++ b/backend/__tests__/unit/services/uiCardValidation.test.js @@ -58,6 +58,7 @@ describe('untrusted UI card write validation', () => { 'root = Badge("Ready", "done")', 'root = Link("Docs", "https://commonly.me/docs", "text")', 'root = RunTrace(3)', + 'root = RunTrace()', 'root = List(["one", "two"], true)', 'root = Text(["Read ", Link("the docs", "https://commonly.me/docs", "text")])', ])('accepts the artboard catalogue: %s', (source) => { diff --git a/backend/config/uiCardCatalogue.json b/backend/config/uiCardCatalogue.json index c5d2b261fa..43e38a567a 100644 --- a/backend/config/uiCardCatalogue.json +++ b/backend/config/uiCardCatalogue.json @@ -368,7 +368,8 @@ "limit": { "type": "integer", "minimum": 1, - "maximum": 5 + "maximum": 5, + "default": 3 } }, "required": [ From 7072632b9cf75449eaf749ac8113115cce168772 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:05:17 -0700 Subject: [PATCH 06/10] fix: reject discarded UI source and cover bot card boundary Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- .../agentsRuntime.post-message.config.test.js | 18 ++++++++++++++++++ .../unit/services/uiCardValidation.test.js | 16 ++++++++++++++++ backend/services/uiCardValidation.ts | 12 ++++++++++++ docs/api/agent-ui-cards.md | 8 ++++++-- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js index e4fc57231c..9d97c4ca87 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js @@ -277,6 +277,24 @@ describe('bot-token send-time freshness', () => { }); }); + it.each(['ui', 'approval'])('validates bot-token card kind %s before writing', async (kind) => { + const payload = { kind, v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; + AgentMessageService.postMessage.mockResolvedValue({ success: true }); + const res = response(); + await getRouteHandler('/bot/pods/:podId/messages', 'post')({ ...req, + body: { content: 'fallback', messageType: 'card', payload, continueAnyway: true } }, res); + if (kind === 'ui') { + expect(AgentMessageService.postMessage).toHaveBeenCalledWith(expect.objectContaining({ + content: 'fallback', messageType: 'card', payload, + })); + expect(res.status).not.toHaveBeenCalled(); + } else { + expect(res.status).toHaveBeenCalledWith(400); + expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); + expect(findNonMemberMentionNotices).not.toHaveBeenCalled(); + } + }); + it('stamps only a successful bot-token message read and holds a stale post', async () => { const messages = [{ id: 'm1', content: 'new message' }]; AgentMessageService.getRecentMessages.mockResolvedValue(messages); diff --git a/backend/__tests__/unit/services/uiCardValidation.test.js b/backend/__tests__/unit/services/uiCardValidation.test.js index 31905385d3..65ec1b3c45 100644 --- a/backend/__tests__/unit/services/uiCardValidation.test.js +++ b/backend/__tests__/unit/services/uiCardValidation.test.js @@ -28,6 +28,12 @@ describe('untrusted UI card write validation', () => { expect(() => validate(card(), 'fallback', 'text')).toThrow(); expect(() => validateAgentCardPayload('card', undefined, 'fallback')).toThrow(); }); + it('rejects source lines discarded by the statement splitter', () => { + expect(() => validate(card('root = Text("hello")\nnot valid'))).toThrow('discarded'); + expect(() => validate(card('garbage\nroot = Text("hello")'))).toThrow('discarded'); + expect(() => validate(card('root = Text("hello")\nunused ='))).toThrow('discarded'); + expect(() => validate(card('root = Text(\n"hello"\n)\n\n'))).not.toThrow(); + }); it.each([ 'root = Unknown("hello")', 'root = Text("hello")\nunused = Unknown("hello")', @@ -94,6 +100,16 @@ describe('untrusted UI card write validation', () => { it('refuses deeply nested input before recursive parsing', () => { expect(() => validate(card('root = Text(' + '['.repeat(1000) + '0' + ']'.repeat(1000) + ')'))).toThrow('nesting'); }); + it('enforces exact nesting and statement count boundaries', () => { + const nested = (count) => 'root = ' + 'Card("title", ['.repeat(count) + + 'Text(["hello"])' + '])'.repeat(count); + expect(() => validate(card(nested(31)))).not.toThrow(); // 64 brackets + expect(() => validate(card(nested(32)))).toThrow('64 nesting'); + const statements = (count) => 'root = Text("hello")\n' + + Array.from({ length: count - 1 }, (_, i) => 'unused' + i + ' = "literal"').join('\n'); + expect(() => validate(card(statements(256)))).not.toThrow(); + expect(() => validate(card(statements(257)))).toThrow('256 statements'); + }); it('limits serialized payload bytes, including multibyte Unicode and exact boundary', () => { const prefix = 'root = Text("'; const suffix = '")'; diff --git a/backend/services/uiCardValidation.ts b/backend/services/uiCardValidation.ts index 6566bb61e3..f5bafcf228 100644 --- a/backend/services/uiCardValidation.ts +++ b/backend/services/uiCardValidation.ts @@ -129,6 +129,18 @@ export function validateAgentCardPayload( }); if (brackets.length) refuse('UI card contains unmatched brackets'); const statements = split(tokens); + const separators = tokenize('\n'); + const separatorTypes = new Set(separators.map((token) => token.t)); + const consumed = new Set(statements.flatMap((statement) => statement.tokens)); + statements.forEach((statement) => { + let index = tokens.indexOf(statement.tokens[0]) - 1; + while (index >= 0 && separatorTypes.has(tokens[index].t)) index -= 1; + consumed.add(tokens[index]); // assignment equals + consumed.add(tokens[index - 1]); // assignment identifier + }); + if (tokens.some((token) => !separatorTypes.has(token.t) && !consumed.has(token))) { + refuse('UI card contains source discarded by the statement splitter'); + } if (statements.length > 256) refuse('UI card exceeds 256 statements'); const ids = new Set(); statements.forEach((statement) => { diff --git a/docs/api/agent-ui-cards.md b/docs/api/agent-ui-cards.md index b9b6c2f20a..cd4000b446 100644 --- a/docs/api/agent-ui-cards.md +++ b/docs/api/agent-ui-cards.md @@ -23,8 +23,12 @@ route uses the same validator. A payload on a non-card message is refused. `commonly_post_message` accepts `messageType` and `payload` alongside its required `content`. The native hosted post tool uses the same schema and validator -before its internal service write. Its run provenance is supplied by the -trusted runtime, separately from model arguments. The CLI takes a JSON envelope: +before its internal service write. This change does not attach run provenance; +TASK-499 will add trusted runtime stamping separately from model arguments. +The CLI takes a JSON envelope +and uses the active profile token, which must be a `cm_agent_` runtime token. +A device token from `commonly login` receives HTTP 401 without a human-writer +fallback: ```sh commonly pod send 'A readable text fallback.' --payload '{"kind":"ui","v":1,"lang":"openui@1","source":"root = Text(\"Hello\")"}' From 877e60957ca0e26ba7f6f9ee7afc423b0bcfcea4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:11:46 -0700 Subject: [PATCH 07/10] fix: fence agent message types and dormant card actions Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- .../agentsRuntime.post-message.config.test.js | 15 +++++++++++++++ .../unit/services/uiCardValidation.test.js | 9 +++++++++ backend/services/uiCardValidation.ts | 9 ++++++++- docs/api/agent-ui-cards.md | 4 ++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js index 9d97c4ca87..455c4dab13 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js @@ -235,6 +235,13 @@ describe('agent card route boundary', () => { })); expect(res.status).not.toHaveBeenCalled(); }); + it('refuses system messages without a payload before persistence', async () => { + const res = response(); + await handler(req({ messageType: 'system', content: 'hello' }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); + expect(findNonMemberMentionNotices).not.toHaveBeenCalled(); + }); it.each([ { ...payload, kind: 'approval' }, { ...payload, source: 'root = Text("hello")\nq = Query("secret", {})' }, @@ -294,6 +301,14 @@ describe('bot-token send-time freshness', () => { expect(findNonMemberMentionNotices).not.toHaveBeenCalled(); } }); + it('refuses system messages without a payload on the bot-token route', async () => { + const res = response(); + await getRouteHandler('/bot/pods/:podId/messages', 'post')({ ...req, + body: { content: 'hello', messageType: 'system', continueAnyway: true } }, res); + expect(res.status).toHaveBeenCalledWith(400); + expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); + expect(findNonMemberMentionNotices).not.toHaveBeenCalled(); + }); it('stamps only a successful bot-token message read and holds a stale post', async () => { const messages = [{ id: 'm1', content: 'new message' }]; diff --git a/backend/__tests__/unit/services/uiCardValidation.test.js b/backend/__tests__/unit/services/uiCardValidation.test.js index 65ec1b3c45..8023a6e459 100644 --- a/backend/__tests__/unit/services/uiCardValidation.test.js +++ b/backend/__tests__/unit/services/uiCardValidation.test.js @@ -9,9 +9,13 @@ describe('untrusted UI card write validation', () => { it('keeps ordinary text backwards compatible', () => { expect(() => validateAgentCardPayload(undefined, undefined, 'hello')).not.toThrow(); }); + it.each(['system', 'approval', null, 1])('refuses non-agent message type %s without a payload', (type) => { + expect(() => validateAgentCardPayload(type, undefined, 'hello')).toThrow('text or card'); + }); it('accepts a complete known component without evaluating it', () => { expect(() => validate()).not.toThrow(); expect(() => validate(card('root = Text("Query Mutation @Run are just quoted text")'))).not.toThrow(); + expect(() => validate(card('root = Text("hello")\nunused = Action(@OpenUrl("https://commonly.me"))'))).not.toThrow(); }); it.each([ null, [], {}, { ...card(), kind: 'approval' }, { ...card(), kind: 'decision' }, @@ -42,6 +46,11 @@ describe('untrusted UI card write validation', () => { 'root = Text("hello")\nunused = Mutation("tool", {})', 'root = Text(Action(@Run(q)))', 'root = Text("hello")\nunused = Action(@Run(q))', + 'root = Text("hello")\nunused = OpenUrl("javascript:alert(1)")', + 'root = Text("hello")\nunused = Action("javascript:alert(1)")', + 'root = Text("hello")\nunused = OpenUrl("http://example.com")', + 'root = Text("hello")\nunused = Action("http://example.com")', + 'root = Text("hello")\nunused = Action(OpenUrl("javascript:alert(1)"))', 'root = Text("hello") Query("tool", {})', 'root = Text("hello") Unknown("hidden")', 'root = Text(@Fetch("https://example.org"))', diff --git a/backend/services/uiCardValidation.ts b/backend/services/uiCardValidation.ts index f5bafcf228..1ce525b24b 100644 --- a/backend/services/uiCardValidation.ts +++ b/backend/services/uiCardValidation.ts @@ -92,6 +92,9 @@ export function validateAgentCardPayload( payload: unknown, content: unknown, ): void { + if (messageType !== undefined && messageType !== 'text' && messageType !== 'card') { + refuse('Agent messageType must be text or card'); + } if (payload === undefined && messageType !== 'card') return; if (messageType !== 'card') refuse('UI payload requires messageType card'); if (typeof content !== 'string' || !content.trim()) refuse('UI card requires a content text fallback'); @@ -154,6 +157,10 @@ export function validateAgentCardPayload( refuse(`UI card call is not allowed: ${node.name}`); } if (node.fn) refuse('UI card functions are not allowed'); + if (node.name === 'Action' && (node.args.length !== 1 + || node.args[0].k !== 'Comp' || node.args[0].name !== 'OpenUrl')) { + refuse('Action requires one parsed OpenUrl call'); + } if (node.name === 'OpenUrl') { const [url] = node.args; if (node.args.length !== 1 || url?.k !== 'Str') refuse('OpenUrl requires a literal https URL'); @@ -162,7 +169,7 @@ export function validateAgentCardPayload( refuse('OpenUrl requires a literal https URL'); } } - } else if (node.k === 'Ref' && ['Query', 'Mutation', 'Run'].includes(node.n)) { + } else if (node.k === 'Ref' && ['Query', 'Mutation', 'Run', 'Action', 'OpenUrl'].includes(node.n)) { refuse(`UI card capability is not allowed: ${node.n}`); } else if (node.k === 'Obj') { if (node.entries.some(([key]) => ['__proto__', 'prototype', 'constructor'].includes(key))) { diff --git a/docs/api/agent-ui-cards.md b/docs/api/agent-ui-cards.md index cd4000b446..5e43bfb7c2 100644 --- a/docs/api/agent-ui-cards.md +++ b/docs/api/agent-ui-cards.md @@ -20,6 +20,8 @@ cannot write their kinds through this surface. Send to `POST /api/agents/runtime/pods/:podId/messages`, using the existing pod-authorized runtime token and freshness rules. The bot API-token message route uses the same validator. A payload on a non-card message is refused. +Both agent routes allow only `text` and `card`; omitting the type keeps the +existing text default. Agent requests cannot post `system` messages. `commonly_post_message` accepts `messageType` and `payload` alongside its required `content`. The native hosted post tool uses the same schema and validator @@ -54,6 +56,8 @@ the source is required before retrying. - Only catalogue components and the `Action` / literal HTTPS `OpenUrl` constructs are permitted. The renderer owns the HTTPS hostname allowlist and gets no tool provider. Server parsing never executes a tool or an action. + Bare `Action` / `OpenUrl` references discarded by expression parsing are refused, + including in declarations that the root does not reference. ## Catalogue v1 From 6e445b3b61c4cb4cd4c481e7b025485f6022ff19 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:21:31 -0700 Subject: [PATCH 08/10] fix: reject source characters omitted by OpenUI lexer Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- .../unit/services/uiCardValidation.test.js | 4 ++++ backend/services/uiCardValidation.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/backend/__tests__/unit/services/uiCardValidation.test.js b/backend/__tests__/unit/services/uiCardValidation.test.js index 8023a6e459..7749722c0d 100644 --- a/backend/__tests__/unit/services/uiCardValidation.test.js +++ b/backend/__tests__/unit/services/uiCardValidation.test.js @@ -16,6 +16,10 @@ describe('untrusted UI card write validation', () => { expect(() => validate()).not.toThrow(); expect(() => validate(card('root = Text("Query Mutation @Run are just quoted text")'))).not.toThrow(); expect(() => validate(card('root = Text("hello")\nunused = Action(@OpenUrl("https://commonly.me"))'))).not.toThrow(); + expect(() => validate(card('root = Text("# hello 🌍")'))).not.toThrow(); + }); + it.each(['#', '🌍', '`', '\\'])('rejects source characters omitted by tokenization: %s', (suffix) => { + expect(() => validate(card('root = Text("hello")' + suffix))).toThrow('unsupported source characters'); }); it.each([ null, [], {}, { ...card(), kind: 'approval' }, { ...card(), kind: 'decision' }, diff --git a/backend/services/uiCardValidation.ts b/backend/services/uiCardValidation.ts index 1ce525b24b..8b1547e3ea 100644 --- a/backend/services/uiCardValidation.ts +++ b/backend/services/uiCardValidation.ts @@ -110,6 +110,20 @@ export function validateAgentCardPayload( } try { const source = envelope.source as string; + // The lexer drops unknown characters. Reject those in the original source, + // while leaving quoted prose (including Unicode and escapes) untouched. + let quote = ''; + let escaped = false; + for (const character of source) { + if (quote) { + if (escaped) escaped = false; + else if (character === '\\') escaped = true; + else if (character === quote) quote = ''; + } else if (character === '"' || character === "'") quote = character; + else if (!/[a-zA-Z0-9_$@\s()[\]{},:=.?!+*/%<>|&-]/.test(character)) { + refuse('UI card contains unsupported source characters'); + } + } // The streaming parser intentionally repairs incomplete input. Stored cards // must be complete. Inspect ALL statements, including unreachable ones: // materializing only root misses a dormant Query or an unknown component. From b83a78b5950ed641b88b42d45f182cafc61c588e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:03:03 -0700 Subject: [PATCH 09/10] feat: pin persisted UI cards to an immutable catalogue revision Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- .../agentsRuntime.post-message.config.test.js | 15 +++++++++-- ...MessageService.threadRootBroadcast.test.js | 3 ++- .../nativeRuntimeService.byoHandoff.test.js | 9 ++++--- .../services/uiCardCatalogueRevision.test.js | 25 +++++++++++++++++++ .../unit/services/uiCardValidation.test.js | 12 ++++++++- backend/config/uiCardCatalogueRevision.json | 5 ++++ backend/services/nativeRuntimeService.ts | 4 +-- backend/services/uiCardValidation.ts | 14 +++++++++-- cli/__tests__/pod-send.test.mjs | 2 +- commonly-mcp/__tests__/tools.test.mjs | 2 +- commonly-mcp/src/tools.js | 2 +- docs/api/agent-ui-cards.md | 19 +++++++++++--- 12 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 backend/__tests__/unit/services/uiCardCatalogueRevision.test.js create mode 100644 backend/config/uiCardCatalogueRevision.json diff --git a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js index 455c4dab13..8b7b915ac0 100644 --- a/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js +++ b/backend/__tests__/unit/routes/agentsRuntime.post-message.config.test.js @@ -1,3 +1,4 @@ +const catalogueRevision = require('../../../config/uiCardCatalogueRevision.json'); jest.mock('../../../middleware/agentRuntimeAuth', () => (req, res, next) => next()); jest.mock('../../../middleware/auth', () => (req, res, next) => next()); jest.mock('../../../middleware/apiTokenScopes', () => ({ @@ -220,12 +221,21 @@ describe('agentsRuntime post message installation config forwarding', () => { describe('agent card route boundary', () => { const installation = { podId: 'pod-1', agentName: 'openclaw', instanceId: 'default', config: {} }; - const payload = { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; + const payload = { ...catalogueRevision, kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; const handler = getRouteHandler('/pods/:podId/messages', 'post'); const req = (body) => ({ params: { podId: 'pod-1' }, body: { continueAnyway: true, ...body }, agentInstallation: installation, agentInstallations: [installation] }); const response = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() }); beforeEach(() => { jest.clearAllMocks(); findNonMemberMentionNotices.mockResolvedValue([]); }); + it('returns a visible 400 reason for an unknown catalogue revision before writing', async () => { + const res = response(); + await handler(req({ messageType: 'card', payload: { ...payload, catalogRev: 999 }, content: 'fallback' }), res); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + code: 'invalid_ui_card', message: expect.stringContaining('catalogRev'), + })); + expect(AgentMessageService.postMessage).not.toHaveBeenCalled(); + }); it('forwards a validated UI payload unchanged to the durable message writer', async () => { AgentMessageService.postMessage.mockResolvedValue({ success: true }); const res = response(); @@ -285,7 +295,7 @@ describe('bot-token send-time freshness', () => { }); it.each(['ui', 'approval'])('validates bot-token card kind %s before writing', async (kind) => { - const payload = { kind, v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; + const payload = { ...catalogueRevision, kind, v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; AgentMessageService.postMessage.mockResolvedValue({ success: true }); const res = response(); await getRouteHandler('/bot/pods/:podId/messages', 'post')({ ...req, @@ -312,6 +322,7 @@ describe('bot-token send-time freshness', () => { it('stamps only a successful bot-token message read and holds a stale post', async () => { const messages = [{ id: 'm1', content: 'new message' }]; + Object.defineProperty(messages, 'readSource', { value: 'postgres' }); AgentMessageService.getRecentMessages.mockResolvedValue(messages); const get = getRouteHandler('/bot/pods/:podId/messages', 'get'); const getRes = response(); diff --git a/backend/__tests__/unit/services/agentMessageService.threadRootBroadcast.test.js b/backend/__tests__/unit/services/agentMessageService.threadRootBroadcast.test.js index 585e8d0174..64e97dbadf 100644 --- a/backend/__tests__/unit/services/agentMessageService.threadRootBroadcast.test.js +++ b/backend/__tests__/unit/services/agentMessageService.threadRootBroadcast.test.js @@ -1,3 +1,4 @@ +const catalogueRevision = require('../../../config/uiCardCatalogueRevision.json'); /** * The agent broadcast must carry `thread_root_id` (#1169's land-alongside * dependency, found in review). @@ -210,7 +211,7 @@ describe('agent broadcast carries author identity', () => { describe('agent UI card payload persistence and live delivery', () => { - const payload = { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; + const payload = { ...catalogueRevision, kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; it('passes the envelope to PostgreSQL and preserves it in result and socket', async () => { PGMessage.create.mockResolvedValue(pgRow({ payload, content: 'UI text fallback', message_type: 'card', thread_root_id: null })); const result = await AgentMessageService.postMessage({ diff --git a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js index f1297e6474..1a43fd8d9e 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.byoHandoff.test.js @@ -1,3 +1,4 @@ +const catalogueRevision = require('../../../config/uiCardCatalogueRevision.json'); /** * A hosted agent asked for local repository access must produce the structured * handoff card, not prose that looks like a command and not a token-bearing @@ -170,7 +171,7 @@ describe('commonly_offer_local_agent', () => { }); test('native post tool forwards a validated UI card and offers the payload schema', async () => { - const payload = { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("native card")' }; + const payload = { ...catalogueRevision, kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("native card")' }; axios.post.mockResolvedValueOnce(callsTool('commonly_post_message', { content: 'Text fallback', messageType: 'card', payload, })).mockResolvedValueOnce(says('NO_REPLY')); @@ -183,9 +184,9 @@ describe('commonly_offer_local_agent', () => { expect(tool.function.parameters.properties.payload.type).toBe('object'); }); test.each([ - { kind: 'approval', v: 1, lang: 'openui@1', source: 'root = Text("forged")' }, - { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hi")\nunused = Query("secret", {})' }, - { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hi")', runId: 'other-run' }, + { ...catalogueRevision, kind: 'approval', v: 1, lang: 'openui@1', source: 'root = Text("forged")' }, + { ...catalogueRevision, kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hi")\nunused = Query("secret", {})' }, + { ...catalogueRevision, kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hi")', runId: 'other-run' }, ])('native post tool refuses unsafe payloads before the writer %#', async (payload) => { axios.post.mockResolvedValueOnce(callsTool('commonly_post_message', { content: 'Text fallback', messageType: 'card', payload, diff --git a/backend/__tests__/unit/services/uiCardCatalogueRevision.test.js b/backend/__tests__/unit/services/uiCardCatalogueRevision.test.js new file mode 100644 index 0000000000..92e6bba51d --- /dev/null +++ b/backend/__tests__/unit/services/uiCardCatalogueRevision.test.js @@ -0,0 +1,25 @@ +const { createHash } = require('crypto'); +const catalogue = require('../../../config/uiCardCatalogue.json'); +const revision = require('../../../config/uiCardCatalogueRevision.json'); + +// Immutable revision 1 ABI. A new catalogue requires a new revision and reader; +// never update these expectations to retrofit a stored revision's meaning. +describe('Commonly UI catalogue revision 1', () => { + it('pins all positional property orders', () => { + expect(Object.fromEntries(Object.entries(catalogue.$defs) + .map(([name, def]) => [name, Object.keys(def.properties)]))).toEqual({ + Card: ['title', 'children', 'meta', 'summary'], Text: ['body'], + List: ['items', 'ordered'], Steps: ['steps'], Step: ['label', 'state', 'detail'], + DiffSummary: ['files'], File: ['path', 'added', 'removed'], + TaskStatus: ['taskId', 'note'], KeyValue: ['pairs'], Pair: ['key', 'value'], + Table: ['columns', 'rows'], Col: ['label', 'type'], Badge: ['label', 'tone'], + Link: ['label', 'url', 'as'], RunTrace: ['limit'], + }); + }); + it('pins the complete schema, identity and revision hash', () => { + const hash = `sha256:${createHash('sha256').update(JSON.stringify(catalogue)).digest('hex')}`; + expect(revision).toEqual({ catalogId: 'commonly.ui', catalogRev: 1, + catalogHash: 'sha256:bb8b4de07f90c06ee48f254d86478b5bd347c738b3e86cf65e2f32e26534db57' }); + expect(hash).toBe(revision.catalogHash); + }); +}); diff --git a/backend/__tests__/unit/services/uiCardValidation.test.js b/backend/__tests__/unit/services/uiCardValidation.test.js index 7749722c0d..1bbd4ac864 100644 --- a/backend/__tests__/unit/services/uiCardValidation.test.js +++ b/backend/__tests__/unit/services/uiCardValidation.test.js @@ -1,11 +1,21 @@ +const catalogueRevision = require('../../../config/uiCardCatalogueRevision.json'); const { validateAgentCardPayload, UI_CARD_MAX_BYTES } = require('../../../services/uiCardValidation'); -const card = (source = 'root = Text("hello")') => ({ kind: 'ui', v: 1, lang: 'openui@1', source }); +const card = (source = 'root = Text("hello")') => ({ ...catalogueRevision, kind: 'ui', v: 1, lang: 'openui@1', source }); const validate = (payload = card(), content = 'Text fallback', type = 'card') => ( validateAgentCardPayload(type, payload, content) ); describe('untrusted UI card write validation', () => { + it.each([ + { catalogId: 'other.ui' }, { catalogRev: 2 }, { catalogRev: '1' }, + { catalogHash: 'sha256:forged' }, { catalogRev: undefined }, + { catalogId: undefined }, { catalogHash: undefined }, + ])('visibly refuses an unsupported catalogue tuple %#', (fields) => { + expect(() => validate({ ...card(), ...fields })).toThrow(expect.objectContaining({ + statusCode: 400, code: 'invalid_ui_card', message: expect.stringMatching(/catalog/), + })); + }); it('keeps ordinary text backwards compatible', () => { expect(() => validateAgentCardPayload(undefined, undefined, 'hello')).not.toThrow(); }); diff --git a/backend/config/uiCardCatalogueRevision.json b/backend/config/uiCardCatalogueRevision.json new file mode 100644 index 0000000000..39bb6eab64 --- /dev/null +++ b/backend/config/uiCardCatalogueRevision.json @@ -0,0 +1,5 @@ +{ + "catalogId": "commonly.ui", + "catalogRev": 1, + "catalogHash": "sha256:bb8b4de07f90c06ee48f254d86478b5bd347c738b3e86cf65e2f32e26534db57" +} diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 725d174829..5ee50e16f9 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -26,7 +26,7 @@ @typescript-eslint/no-require-imports */ import { Types } from 'mongoose'; -import { validateAgentCardPayload } from './uiCardValidation'; +import { validateAgentCardPayload, UI_CARD_CATALOGUE } from './uiCardValidation'; import { isTaskPriority, TASK_PRIORITY_ORDER } from '../utils/taskPriority'; import axios, { AxiosError } from 'axios'; import { @@ -280,7 +280,7 @@ export const TOOLS = [ properties: { content: { type: 'string', description: 'Markdown-supported message content and required UI text fallback' }, messageType: { type: 'string', enum: ['text', 'card'] }, - payload: { type: 'object', description: 'Optional {kind:"ui",v:1,lang:"openui@1",source}. Requires messageType card; validated locally before writing.' }, + payload: { type: 'object', description: `Optional {kind:"ui",v:1,lang:"openui@1",source,...catalogue}. Catalogue fields: ${JSON.stringify(UI_CARD_CATALOGUE)}. Requires messageType card; validated locally before writing.` }, }, required: ['content'], }, diff --git a/backend/services/uiCardValidation.ts b/backend/services/uiCardValidation.ts index 8b1547e3ea..c84705cdac 100644 --- a/backend/services/uiCardValidation.ts +++ b/backend/services/uiCardValidation.ts @@ -3,7 +3,14 @@ import { } from '@openuidev/lang-core'; import type { ElementNode, LibraryJSONSchema } from '@openuidev/lang-core'; import Ajv from 'ajv'; +import { createHash } from 'crypto'; import catalogue from '../config/uiCardCatalogue.json'; +import revision from '../config/uiCardCatalogueRevision.json'; + +// A revision is immutable: property insertion order is OpenUI's positional ABI. +export const UI_CARD_CATALOGUE = Object.freeze({ ...revision }); +const catalogueHash = `sha256:${createHash('sha256').update(JSON.stringify(catalogue)).digest('hex')}`; +if (catalogueHash !== revision.catalogHash) throw new Error('UI catalogue revision hash mismatch'); export const UI_CARD_MAX_BYTES = 32 * 1024; const schema = catalogue as LibraryJSONSchema; @@ -102,9 +109,12 @@ export function validateAgentCardPayload( const envelope = payload as Record; if (envelope.kind !== 'ui' || envelope.v !== 1 || envelope.lang !== 'openui@1' || typeof envelope.source !== 'string' || !envelope.source.trim() - || Object.keys(envelope).some((key) => !['kind', 'v', 'lang', 'source'].includes(key))) { - refuse('Expected {kind:"ui",v:1,lang:"openui@1",source}'); + || Object.keys(envelope).some((key) => !['kind', 'v', 'lang', 'source', 'catalogId', 'catalogRev', 'catalogHash'].includes(key))) { + refuse('Expected {kind:"ui",v:1,lang:"openui@1",catalogId,catalogRev,catalogHash,source}'); } + if (envelope.catalogId !== revision.catalogId) refuse('Unsupported UI catalogId'); + if (envelope.catalogRev !== revision.catalogRev) refuse('Unsupported UI catalogRev'); + if (envelope.catalogHash !== revision.catalogHash) refuse('UI catalogHash does not match catalogRev'); if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > UI_CARD_MAX_BYTES) { refuse('UI card payload exceeds 32 KB'); } diff --git a/cli/__tests__/pod-send.test.mjs b/cli/__tests__/pod-send.test.mjs index 599234a31b..988e544610 100644 --- a/cli/__tests__/pod-send.test.mjs +++ b/cli/__tests__/pod-send.test.mjs @@ -21,7 +21,7 @@ beforeEach(() => { afterEach(() => jest.restoreAllMocks()); test('sends UI JSON on the real pod command with the content fallback', async () => { - const payload = { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hi")' }; + const payload = { catalogId: 'commonly.ui', catalogRev: 1, catalogHash: 'sha256:bb8b4de07f90c06ee48f254d86478b5bd347c738b3e86cf65e2f32e26534db57', kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hi")' }; await run('--payload', JSON.stringify(payload)); expect(post).toHaveBeenCalledTimes(1); expect(post).toHaveBeenCalledWith('/api/agents/runtime/pods/pod-1/messages', { diff --git a/commonly-mcp/__tests__/tools.test.mjs b/commonly-mcp/__tests__/tools.test.mjs index 03d2caf739..752b1fdfa5 100644 --- a/commonly-mcp/__tests__/tools.test.mjs +++ b/commonly-mcp/__tests__/tools.test.mjs @@ -76,7 +76,7 @@ afterEach(() => { delete global.fetch; }); describe('commonly_post_message', () => { it('forwards UI card payload and explicit type alongside the text fallback', async () => { const fetchSpy = installFetch(async () => okResponse({ id: 'card1' })); - const payload = { kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; + const payload = { catalogId: 'commonly.ui', catalogRev: 1, catalogHash: 'sha256:bb8b4de07f90c06ee48f254d86478b5bd347c738b3e86cf65e2f32e26534db57', kind: 'ui', v: 1, lang: 'openui@1', source: 'root = Text("hello")' }; await byName.commonly_post_message.call({ podId: 'p', content: 'fallback', messageType: 'card', payload }); expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ content: 'fallback', messageType: 'card', payload }); expect(byName.commonly_post_message.inputSchema.properties.messageType.enum).toContain('card'); diff --git a/commonly-mcp/src/tools.js b/commonly-mcp/src/tools.js index e9a743f8fc..e4bbc724a7 100644 --- a/commonly-mcp/src/tools.js +++ b/commonly-mcp/src/tools.js @@ -185,7 +185,7 @@ export const buildTools = (config) => { continueAnyway: { type: 'boolean' }, metadata: { type: 'object', additionalProperties: true }, messageType: { type: 'string', enum: ['text', 'card'] }, - payload: { type: 'object', description: 'UI card only: {kind:"ui",v:1,lang:"openui@1",source}. Requires messageType card and content text fallback; validated by the server.', additionalProperties: true }, + payload: { type: 'object', description: 'UI card only: {kind:"ui",v:1,lang:"openui@1",catalogId,catalogRev,catalogHash,source}. Copy the exact catalogue tuple from the published catalogue revision manifest. Requires messageType card and content text fallback; unknown catalogue revisions return 400.', additionalProperties: true }, }, ['podId', 'content']), call: wrap(async ({ podId, content, replyToMessageId, threadRootId, metadata, continueAnyway, messageType, payload }) => request(config, { method: 'POST', diff --git a/docs/api/agent-ui-cards.md b/docs/api/agent-ui-cards.md index 5e43bfb7c2..7e841b739d 100644 --- a/docs/api/agent-ui-cards.md +++ b/docs/api/agent-ui-cards.md @@ -12,6 +12,9 @@ cannot write their kinds through this surface. "kind": "ui", "v": 1, "lang": "openui@1", + "catalogId": "commonly.ui", + "catalogRev": 1, + "catalogHash": "sha256:bb8b4de07f90c06ee48f254d86478b5bd347c738b3e86cf65e2f32e26534db57", "source": "root = Text(\"Hello\")" } } @@ -33,7 +36,7 @@ A device token from `commonly login` receives HTTP 401 without a human-writer fallback: ```sh -commonly pod send 'A readable text fallback.' --payload '{"kind":"ui","v":1,"lang":"openui@1","source":"root = Text(\"Hello\")"}' +commonly pod send 'A readable text fallback.' --payload '{"kind":"ui","v":1,"lang":"openui@1","catalogId":"commonly.ui","catalogRev":1,"catalogHash":"sha256:bb8b4de07f90c06ee48f254d86478b5bd347c738b3e86cf65e2f32e26534db57","source":"root = Text(\"Hello\")"}' ``` Cards are sent only through the validated agent writer. Unlike the existing @@ -43,6 +46,9 @@ the source is required before retrying. ## Write boundary +- `catalogId`, `catalogRev` and `catalogHash` are required and must match the + supported revision manifest. Unknown IDs/revisions and mismatched hashes return + HTTP 400 with an explicit reason; the server never stamps a missing tuple. - Envelope version is exactly `v: 1`, language is exactly `openui@1`. Additional client fields (including a claimed `runId`) are refused. - The whole JSON envelope, serialized in UTF-8, must be at most 32,768 bytes. @@ -79,8 +85,15 @@ must be credential-free HTTPS; the renderer further restricts hostnames. The canonical serializable catalogue is `backend/config/uiCardCatalogue.json`. TASK-498 generates the frontend copy and checks drift because the backend and frontend images have separate Docker build contexts. Names and positional prop -order are part of the stored `openui@1` contract; incompatible changes need a -new envelope/language version and reader compatibility. +order are part of the stored contract. The immutable revision tuple is in +`backend/config/uiCardCatalogueRevision.json`. `catalogHash` is `sha256:` plus the +lowercase SHA-256 of `JSON.stringify` applied to the parsed catalogue, preserving +property insertion order. A fixed revision-1 order/hash test fails if any schema +or prop order changes. Never update that revision in place: a changed schema +requires a new revision and a reader that explicitly supports it. The frontend +generates a byte-identical manifest copy and chooses its renderer by the full +tuple. Unsupported tuples show the required text fallback with an unsupported +note, never the current catalogue as a guess. The parser is MIT licensed. Installation telemetry was disabled for the local install (`npm install --ignore-scripts`); its runtime telemetry is opt-in and is From 9467bbf2faf3f842c689c575fa1e38f8a70bb9ab Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:22:19 -0700 Subject: [PATCH 10/10] fix: bound UI reference expansion before materialization Co-authored-by: tern Agent-Model: codex/gpt-6.1-sol/medium --- .../services/uiCardExpansionBudget.test.js | 41 ++++++++++++++++ .../unit/services/uiCardValidation.test.js | 5 ++ backend/services/uiCardValidation.ts | 48 ++++++++++++++++++- commonly-mcp/__tests__/tools.test.mjs | 4 ++ commonly-mcp/src/tools.js | 2 +- docs/api/agent-ui-cards.md | 10 ++-- 6 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 backend/__tests__/unit/services/uiCardExpansionBudget.test.js diff --git a/backend/__tests__/unit/services/uiCardExpansionBudget.test.js b/backend/__tests__/unit/services/uiCardExpansionBudget.test.js new file mode 100644 index 0000000000..bc63f8b453 --- /dev/null +++ b/backend/__tests__/unit/services/uiCardExpansionBudget.test.js @@ -0,0 +1,41 @@ +const mockMaterialize = jest.fn(() => { throw new Error('materializer must not run'); }); +jest.mock('@openuidev/lang-core', () => ({ + ...jest.requireActual('@openuidev/lang-core'), + createParser: () => ({ parse: mockMaterialize }), +})); +const { validateAgentCardPayload } = require('../../../services/uiCardValidation'); +const revision = require('../../../config/uiCardCatalogueRevision.json'); +const validate = (source) => validateAgentCardPayload('card', { + ...revision, kind: 'ui', v: 1, lang: 'openui@1', source, +}, 'Text fallback'); + +describe('reference budgets run before materialization', () => { + const sixWay = (dormant) => { + const lines = ['a0 = Text("leaf")']; + for (let level = 1; level <= 12; level += 1) { + lines.push(`a${level} = Card("repeat", [${Array(6).fill(`a${level - 1}`).join(',')}])`); + } + lines.push(dormant ? 'root = Text("safe")' : 'root = a12'); + return lines.join('\n'); + }; + it.each([false, true])('refuses exponential six-way expansion, including dormant=%s', (dormant) => { + expect(() => validate(sixWay(dormant))).toThrow('reference expansion budget'); + expect(mockMaterialize).not.toHaveBeenCalled(); + }); + it('refuses a long alias chain before recursive materialization', () => { + const lines = ['root = a140', 'a0 = Text("leaf")']; + for (let i = 1; i <= 140; i += 1) lines.push(`a${i} = a${i - 1}`); + expect(() => validate(lines.join('\n'))).toThrow('reference expansion budget'); + expect(mockMaterialize).not.toHaveBeenCalled(); + }); + it('allows exactly 2048 expanded nodes to reach the materializer', () => { + const source = 'root = Text("safe")\nunused = [' + Array(2045).fill('null').join(',') + ']'; + expect(() => validate(source)).toThrow('could not be parsed'); + expect(mockMaterialize).toHaveBeenCalledTimes(1); + }); + it('rejects the first node beyond the budget before materialization', () => { + const source = 'root = Text("safe")\nunused = [' + Array(2046).fill('null').join(',') + ']'; + expect(() => validate(source)).toThrow('reference expansion budget'); + expect(mockMaterialize).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/__tests__/unit/services/uiCardValidation.test.js b/backend/__tests__/unit/services/uiCardValidation.test.js index 1bbd4ac864..9891649cd7 100644 --- a/backend/__tests__/unit/services/uiCardValidation.test.js +++ b/backend/__tests__/unit/services/uiCardValidation.test.js @@ -123,6 +123,11 @@ describe('untrusted UI card write validation', () => { it('refuses deeply nested input before recursive parsing', () => { expect(() => validate(card('root = Text(' + '['.repeat(1000) + '0' + ']'.repeat(1000) + ')'))).toThrow('nesting'); }); + it('accepts bounded reuse and rejects direct or indirect reference cycles', () => { + expect(() => validate(card('leaf = Text("hello")\nroot = Card("Reuse", [leaf, leaf])'))).not.toThrow(); + expect(() => validate(card('root = Card("cycle", [root])'))).toThrow('cyclic references'); + expect(() => validate(card('a = b\nb = a\nroot = Text("safe")'))).toThrow('cyclic references'); + }); it('enforces exact nesting and statement count boundaries', () => { const nested = (count) => 'root = ' + 'Card("title", ['.repeat(count) + 'Text(["hello"])' + '])'.repeat(count); diff --git a/backend/services/uiCardValidation.ts b/backend/services/uiCardValidation.ts index c84705cdac..1ea75eea3d 100644 --- a/backend/services/uiCardValidation.ts +++ b/backend/services/uiCardValidation.ts @@ -1,7 +1,7 @@ import { createParser, parseExpression, split, tokenize, walkAST, } from '@openuidev/lang-core'; -import type { ElementNode, LibraryJSONSchema } from '@openuidev/lang-core'; +import type { ASTNode, ElementNode, LibraryJSONSchema } from '@openuidev/lang-core'; import Ajv from 'ajv'; import { createHash } from 'crypto'; import catalogue from '../config/uiCardCatalogue.json'; @@ -13,6 +13,7 @@ const catalogueHash = `sha256:${createHash('sha256').update(JSON.stringify(catal if (catalogueHash !== revision.catalogHash) throw new Error('UI catalogue revision hash mismatch'); export const UI_CARD_MAX_BYTES = 32 * 1024; +export const UI_CARD_MAX_EXPANDED_NODES = 2048; const schema = catalogue as LibraryJSONSchema; const parser = createParser(schema); const componentNames = new Set(Object.keys(schema.properties || {})); @@ -93,6 +94,45 @@ function refuse(message: string): never { throw new UICardValidationError(message); } +/** Bound reference substitution before lang-core allocates a materialized tree. */ +function validateExpansionBudget(expressions: Map): void { + type Cost = { nodes: number; depth: number }; + const measured = new Map(); + const visiting = new Set(); + function measureStatement(id: string): Cost { + if (visiting.has(id)) refuse('UI card contains cyclic references'); + const cached = measured.get(id); + if (cached) return cached; + visiting.add(id); + const cost = measureNode(expressions.get(id)!); + visiting.delete(id); + measured.set(id, cost); + return cost; + } + function measureNode(node: ASTNode): Cost { + let children: Cost[] = []; + if (node.k === 'Ref' && expressions.has(node.n)) children = [measureStatement(node.n)]; + else if (node.k === 'Comp') children = [...node.args, ...Object.values(node.mappedProps || {})].map(measureNode); + else if (node.k === 'Arr') children = node.els.map(measureNode); + else if (node.k === 'Obj') children = node.entries.map(([, value]) => measureNode(value)); + const cost = { nodes: 1, depth: 1 }; + for (const child of children) { + cost.nodes += child.nodes; + cost.depth = Math.max(cost.depth, child.depth + 1); + if (cost.nodes > UI_CARD_MAX_EXPANDED_NODES || cost.depth > 128) { + refuse('UI card exceeds reference expansion budget (2048 nodes / 128 levels)'); + } + } + return cost; + } + // Include dormant statements: the parser may materialize more than root. + let total = 0; + for (const id of expressions.keys()) { + total += measureStatement(id).nodes; + if (total > UI_CARD_MAX_EXPANDED_NODES) refuse('UI card exceeds reference expansion budget (2048 nodes)'); + } +} + /** Validate untrusted CAP input, never privileged kernel-composed payloads. */ export function validateAgentCardPayload( messageType: unknown, @@ -170,12 +210,15 @@ export function validateAgentCardPayload( } if (statements.length > 256) refuse('UI card exceeds 256 statements'); const ids = new Set(); + const expressions = new Map(); statements.forEach((statement) => { if (ids.has(statement.id) || statement.id.startsWith('$')) { refuse('UI card contains duplicate or reactive statements'); } ids.add(statement.id); - walkAST(parseExpression(statement.tokens), (node) => { + const expression = parseExpression(statement.tokens); + expressions.set(statement.id, expression); + walkAST(expression, (node) => { if (node.k === 'Comp') { if (!allowedCall(node.name)) { refuse(`UI card call is not allowed: ${node.name}`); @@ -204,6 +247,7 @@ export function validateAgentCardPayload( } }); }); + validateExpansionBudget(expressions); const result = parser.parse(source); if (!result.root || result.meta.incomplete || result.meta.unresolved.length || result.meta.errors.length || result.queryStatements.length || result.mutationStatements.length diff --git a/commonly-mcp/__tests__/tools.test.mjs b/commonly-mcp/__tests__/tools.test.mjs index 752b1fdfa5..918f23a1b8 100644 --- a/commonly-mcp/__tests__/tools.test.mjs +++ b/commonly-mcp/__tests__/tools.test.mjs @@ -81,6 +81,10 @@ describe('commonly_post_message', () => { expect(JSON.parse(fetchSpy.mock.calls[0][1].body)).toEqual({ content: 'fallback', messageType: 'card', payload }); expect(byName.commonly_post_message.inputSchema.properties.messageType.enum).toContain('card'); expect(byName.commonly_post_message.inputSchema.properties.payload.type).toBe('object'); + const description = byName.commonly_post_message.inputSchema.properties.payload.description; + expect(description).toContain(`catalogId:"${payload.catalogId}"`); + expect(description).toContain(`catalogRev:${payload.catalogRev}`); + expect(description).toContain(payload.catalogHash); }); it('POSTs to the right path with body', async () => { diff --git a/commonly-mcp/src/tools.js b/commonly-mcp/src/tools.js index e4bbc724a7..eb36589e85 100644 --- a/commonly-mcp/src/tools.js +++ b/commonly-mcp/src/tools.js @@ -185,7 +185,7 @@ export const buildTools = (config) => { continueAnyway: { type: 'boolean' }, metadata: { type: 'object', additionalProperties: true }, messageType: { type: 'string', enum: ['text', 'card'] }, - payload: { type: 'object', description: 'UI card only: {kind:"ui",v:1,lang:"openui@1",catalogId,catalogRev,catalogHash,source}. Copy the exact catalogue tuple from the published catalogue revision manifest. Requires messageType card and content text fallback; unknown catalogue revisions return 400.', additionalProperties: true }, + payload: { type: 'object', description: 'UI card only: {kind:"ui",v:1,lang:"openui@1",catalogId,catalogRev,catalogHash,source}. Use catalogId:"commonly.ui", catalogRev:1, catalogHash:"sha256:bb8b4de07f90c06ee48f254d86478b5bd347c738b3e86cf65e2f32e26534db57". Requires messageType card and content text fallback; unknown catalogue revisions return 400.', additionalProperties: true }, }, ['podId', 'content']), call: wrap(async ({ podId, content, replyToMessageId, threadRootId, metadata, continueAnyway, messageType, payload }) => request(config, { method: 'POST', diff --git a/docs/api/agent-ui-cards.md b/docs/api/agent-ui-cards.md index 7e841b739d..8cd0612d23 100644 --- a/docs/api/agent-ui-cards.md +++ b/docs/api/agent-ui-cards.md @@ -55,6 +55,9 @@ the source is required before retrying. - Content must contain a nonempty text fallback after agent content sanitization. - `@openuidev/lang-core` 1.0.1 parses the source against the Commonly catalogue. It must produce a complete root without unresolved references or prop errors. +- Before materialization, a memoized reference-graph pass rejects cycles, more + than 2,048 expanded AST nodes across all statements, or expansion depth over + 128. Repeated references are charged for every use, including dormant code. - Every statement is inspected, including unreachable statements. Unknown component calls, Query, Mutation, Run, custom functions, reactive expressions, duplicate definitions, prototype keys, over 256 statements and over 64 nested @@ -90,10 +93,9 @@ order are part of the stored contract. The immutable revision tuple is in lowercase SHA-256 of `JSON.stringify` applied to the parsed catalogue, preserving property insertion order. A fixed revision-1 order/hash test fails if any schema or prop order changes. Never update that revision in place: a changed schema -requires a new revision and a reader that explicitly supports it. The frontend -generates a byte-identical manifest copy and chooses its renderer by the full -tuple. Unsupported tuples show the required text fallback with an unsupported -note, never the current catalogue as a guess. +requires a new revision and a reader that explicitly supports it. TASK-498 will generate a byte-identical frontend manifest copy and select its +renderer by the full tuple. Unsupported tuples must show the required text +fallback with an unsupported note, never the current catalogue as a guess. The parser is MIT licensed. Installation telemetry was disabled for the local install (`npm install --ignore-scripts`); its runtime telemetry is opt-in and is