From 47dda19dedec646f9ba38d7c5973bf0780e53844 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sat, 28 Mar 2026 06:04:26 +0900 Subject: [PATCH] fix: block malformed structured silent envelope leaks --- src/message-runtime.test.ts | 59 ++++++++++++++++++++++++++++++++++ src/output-suppression.test.ts | 15 +++++++++ src/output-suppression.ts | 9 +++++- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 52d3c34..5bce53e 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -1791,6 +1791,65 @@ describe('createMessageRuntime', () => { expect(channel.sendAndTrack).not.toHaveBeenCalled(); }); + it('suppresses a malformed structured silent envelope without sending a visible message', async () => { + const chatJid = 'group@test'; + const group = makeGroup('codex'); + const channel = makeChannel(chatJid); + const lastAgentTimestamps: Record = {}; + const saveState = vi.fn(); + + vi.mocked(config.isClaudeService).mockReturnValue(false); + vi.mocked(config.isReviewService).mockReturnValue(true); + vi.mocked(db.getMessagesSince).mockReturnValue([ + { + id: 'msg-1', + chat_jid: chatJid, + sender: 'user@test', + sender_name: 'User', + content: 'hello', + timestamp: '2026-03-19T00:00:00.000Z', + seq: 1, + }, + ]); + + vi.mocked(agentRunner.runAgentProcess).mockResolvedValue({ + status: 'success', + result: '{"ejclaw":{"visibility":"silent"}} extra', + newSessionId: 'session-malformed-structured-silent', + }); + + const runtime = createMessageRuntime({ + assistantName: 'Andy', + idleTimeout: 1_000, + pollInterval: 1_000, + timezone: 'UTC', + triggerPattern: /^@Andy\b/i, + channels: [channel], + queue: { + registerProcess: vi.fn(), + closeStdin: vi.fn(), + notifyIdle: vi.fn(), + } as any, + getRegisteredGroups: () => ({ [chatJid]: group }), + getSessions: () => ({}), + getLastTimestamp: () => '', + setLastTimestamp: vi.fn(), + getLastAgentTimestamps: () => lastAgentTimestamps, + saveState, + persistSession: vi.fn(), + clearSession: vi.fn(), + }); + + const result = await runtime.processGroupMessages(chatJid, { + runId: 'run-malformed-structured-silent', + reason: 'messages', + }); + + expect(result).toBe(true); + expect(channel.sendMessage).not.toHaveBeenCalled(); + expect(channel.sendAndTrack).not.toHaveBeenCalled(); + }); + it('resets tracked progress after a final output that becomes empty after formatting', async () => { vi.useFakeTimers(); const chatJid = 'group@test'; diff --git a/src/output-suppression.test.ts b/src/output-suppression.test.ts index a824929..aaac6fc 100644 --- a/src/output-suppression.test.ts +++ b/src/output-suppression.test.ts @@ -51,6 +51,21 @@ describe('classifySuppressTokenOutput', () => { ), ).toBe('exact'); }); + + it('treats a truncated structured silent envelope as mixed', () => { + expect( + classifySuppressTokenOutput('{"ejclaw":{"visibility":"silent"', undefined), + ).toBe('mixed'); + }); + + it('treats a structured silent envelope mixed with extra text as mixed', () => { + expect( + classifySuppressTokenOutput( + '{"ejclaw":{"visibility":"silent"}} extra', + undefined, + ), + ).toBe('mixed'); + }); }); describe('parseStructuredOutputEnvelope', () => { diff --git a/src/output-suppression.ts b/src/output-suppression.ts index efeccab..8edd5a3 100644 --- a/src/output-suppression.ts +++ b/src/output-suppression.ts @@ -9,6 +9,8 @@ import type { StructuredAgentOutput } from './types.js'; const ANY_SUPPRESS_TOKEN_PATTERN = /__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?/g; const EXACT_ANY_SUPPRESS_TOKEN_PATTERN = /^__EJ_SUPPRESS_[a-f0-9]{24,}(?:__)?$/; +const STRUCTURED_SILENT_OUTPUT_PREFIX_PATTERN = + /^\s*\{\s*"ejclaw"\s*:\s*\{\s*"visibility"\s*:\s*"silent"/; export const STRUCTURED_SILENT_OUTPUT_ENVELOPE = '{"ejclaw":{"visibility":"silent"}}'; @@ -43,7 +45,12 @@ export function classifySuppressTokenOutput( return 'mixed'; } ANY_SUPPRESS_TOKEN_PATTERN.lastIndex = 0; - return ANY_SUPPRESS_TOKEN_PATTERN.test(rawText) ? 'mixed' : 'none'; + if (ANY_SUPPRESS_TOKEN_PATTERN.test(rawText)) { + return 'mixed'; + } + return STRUCTURED_SILENT_OUTPUT_PREFIX_PATTERN.test(trimmed) + ? 'mixed' + : 'none'; } export function parseStructuredOutputEnvelope(