{"record":{"id":"7484e99c94dc1d10","repo":"lobehub/lobehub","slug":"forbidden-7484e9","errorCode":"FORBIDDEN","errorMessage":"Message does not belong to this topic","messagePattern":"Message does not belong to this topic","errorType":"exception","errorClass":"TRPCError","httpStatus":403,"severity":"error","filePath":"apps/server/src/routers/lambda/agentNotify.ts","lineNumber":303,"sourceCode":"    // 2a. Assistant mode: write message directly without triggering LLM\n    if (role === 'assistant') {\n      try {\n        // Resolve the target message ID:\n        // 1. Caller-supplied messageId (subsequent notify calls with --message-id)\n        // 2. Placeholder assistantMessageId seeded by execAgent (first notify call for remote hetero)\n        // Using the placeholder avoids creating a second empty bubble in the UI.\n        const placeholderMessageId = (topic.metadata as any)?.runningOperation\n          ?.assistantMessageId as string | undefined;\n        const resolvedMessageId = messageId ?? placeholderMessageId;\n\n        // Update existing message if we have a resolved target\n        if (resolvedMessageId) {\n          // Security: verify the message belongs to this topic before writing.\n          // MessageModel.update scopes only by userId; without this check, a remote\n          // runtime could overwrite messages from other conversations.\n          const existingMsg = await ctx.messageModel.findById(resolvedMessageId);\n          if (!existingMsg || existingMsg.topicId !== topicId) {\n            throw new TRPCError({\n              code: 'FORBIDDEN',\n              message: 'Message does not belong to this topic',\n            });\n          }\n\n          // Terminal signal (done or error) with empty content + existing\n          // placeholder → just finalize the run, no message update. Pass the\n          // resolved id so the finalizer can reload the agent's final reply\n          // (written in-place via earlier `lh notify` calls) into\n          // `lastAssistantContent` — bot completion callbacks and the task\n          // lifecycle follow-ups (handoff / auto-review / brief) depend on it.\n          if (isTerminal && !content) {\n            void publishRemoteHeteroEvent(resolvedMessageId);\n            return { messageId: resolvedMessageId, operationId: undefined, topicId };\n          }\n          await ctx.messageModel.update(resolvedMessageId, { content });\n          void publishRemoteHeteroEvent(resolvedMessageId);\n          if (shouldContinue) {","sourceCodeStart":285,"sourceCodeEnd":321,"githubUrl":"https://github.com/lobehub/lobehub/blob/10f24d7ade75139093a9373b364f6bc91f3cd7db/apps/server/src/routers/lambda/agentNotify.ts#L285-L321","documentation":"A FORBIDDEN security guard in the notify assistant-mode branch. When a remote runtime asks to update an existing message (via messageId or the runningOperation placeholder), the server loads that message and verifies its topicId matches the request's topicId. MessageModel.update scopes only by userId, so without this check a compromised or buggy remote runtime could overwrite messages in other conversations belonging to the same user. This is a cross-conversation isolation enforcement.","triggerScenarios":"A notify call with role='assistant' and a messageId (or a topic with a runningOperation.assistantMessageId placeholder) where the resolved message's topicId differs from the request topicId. Happens if a remote agent replays a stale messageId from a previous run, sends the wrong messageId, or if the placeholder metadata points at a message from a different topic.","commonSituations":"Remote hetero agent (openclaw/hermes) caches a messageId from a prior run and reuses it after the topic changed; client passes a messageId harvested from a different conversation; a race where runningOperation metadata was not cleared and points at an orphaned message; test fixtures that share message ids across topics.","solutions":["Do not reuse messageIds across topics — each notify update target must come from the same topic's prior notify/create response.","If using the placeholder path, ensure runningOperation.assistantMessageId was seeded for THIS topic (the execAgent that started the run set it).","On the client/runtime side, track the messageId returned by the first notify call and pass it back only within the same topicId.","If the error persists, verify no concurrent runs overwrote topic.metadata.runningOperation with a foreign assistantMessageId."],"exampleFix":"// before — reusing a messageId from another topic\nnotify({ topicId: 'B', role: 'assistant', messageId: 'msg-from-topic-A', content });\n\n// after — use the messageId returned by this topic's first notify\nconst { messageId } = await notify({ topicId: 'B', role: 'assistant', content });\nawait notify({ topicId: 'B', role: 'assistant', messageId, content: 'updated' });","handlingStrategy":"validation","validationCode":"// Before updating a message via notify, verify it belongs to the topic\nif (messageId) {\n  const msg = await client.message.getById.query({ id: messageId });\n  if (!msg || msg.topicId !== topicId) {\n    throw new Error('Message/topic mismatch — do not send update');\n  }\n}\nawait client.agentNotify.notify.mutate({ topicId, role: 'assistant', messageId, content });","typeGuard":"const messageBelongsToTopic = (\n  msg: { topicId?: string | null } | null | undefined,\n  expectedTopicId: string,\n): msg is { topicId: string } =>\n  !!msg && msg.topicId === expectedTopicId;","tryCatchPattern":"try {\n  await client.agentNotify.notify.mutate({ topicId, role: 'assistant', messageId, content });\n} catch (e) {\n  if (isTRPCError(e, 'FORBIDDEN') && /does not belong/.test(e.message)) {\n    // Discard the stale messageId, create a new message instead of updating\n    await client.agentNotify.notify.mutate({ topicId, role: 'assistant', content });\n  }\n}","preventionTips":["Never reuse a messageId across different topics — track it per-topic on the caller side.","Use the messageId returned by the first notify call within the same topic only.","If the remote runtime caches messageIds, scope the cache by topicId."],"tags":["trpc","agent-notify","security","authorization","forbidden","message"],"backgroundTag":null,"analyzedSha":"10f24d7ade75139093a9373b364f6bc91f3cd7db","analyzedAt":"2026-08-12T11:43:19.543Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}