Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -218,6 +219,65 @@ describe('agentsRuntime post message installation config forwarding', () => {
});
});

describe('agent card route boundary', () => {
const installation = { podId: 'pod-1', agentName: 'openclaw', instanceId: 'default', config: {} };
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();
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('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", {})' },
{ ...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('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);
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,
Expand All @@ -234,8 +294,35 @@ describe('bot-token send-time freshness', () => {
});
});

it.each(['ui', 'approval'])('validates bot-token card kind %s before writing', async (kind) => {
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,
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('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' }];
Object.defineProperty(messages, 'readSource', { value: 'postgres' });
AgentMessageService.getRecentMessages.mockResolvedValue(messages);
const get = getRouteHandler('/bot/pods/:podId/messages', 'get');
const getRes = response();
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -207,3 +208,37 @@ describe('agent broadcast carries author identity', () => {
}));
});
});


describe('agent UI card payload persistence and live delivery', () => {
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({
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);
});
});
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,7 +17,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 }),
Expand Down Expand Up @@ -166,6 +170,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 = { ...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'));
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([
{ ...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,
})).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', {
Expand Down
25 changes: 25 additions & 0 deletions backend/__tests__/unit/services/uiCardCatalogueRevision.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
41 changes: 41 additions & 0 deletions backend/__tests__/unit/services/uiCardExpansionBudget.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading