Skip to content
Closed
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ public ConversationEntity getOrCreateConversation(String conversationId, Long ag
public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId,
String username, Long workspaceId,
String sessionId) {
return getOrCreateWebchatConversation(conversationId, agentId, username, workspaceId, sessionId, null);
return getOrCreateWebchatConversation(conversationId, agentId, username, workspaceId, sessionId, null, null);
}

/**
Expand All @@ -381,6 +381,22 @@ public ConversationEntity getOrCreateWebchatConversation(String conversationId,
public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId,
String username, Long workspaceId,
String sessionId, String title) {
return getOrCreateWebchatConversation(conversationId, agentId, username, workspaceId, sessionId, title, null);
}

/**
* WebChat get-or-create that also persists the owning {@code channelId} on
* insert (see V171, #558), so /sessions can filter by channel exactly
* instead of matching the collision-prone conversationId prefix.
* <p>
* {@code channelId} is written only when the row is first created; an
* existing row is left as-is (its channel, or lack of one for pre-fix
* rows, is never retroactively rewritten here).
*/
@Transactional
public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId,
String username, Long workspaceId,
String sessionId, String title, Long channelId) {
boolean existed = conversationMapper.selectOne(new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId)) != null;
ConversationEntity conv = getOrCreateConversation(conversationId, agentId, username, workspaceId);
Expand All @@ -394,6 +410,10 @@ public ConversationEntity getOrCreateWebchatConversation(String conversationId,
conv.setTitle(title.trim());
dirty = true;
}
if (channelId != null && conv.getChannelId() == null) {
conv.setChannelId(channelId);
dirty = true;
}
if (dirty) {
conversationMapper.updateById(conv);
}
Expand All @@ -419,6 +439,25 @@ public List<ConversationEntity> listWebchatConversations(String username) {
.orderByDesc(ConversationEntity::getLastActiveTime));
}

/**
* Channel-scoped variant of {@link #listWebchatConversations(String)} (#558).
* Returns rows for this visitor whose {@code channel_id} matches exactly,
* <em>plus</em> pre-fix rows where {@code channel_id IS NULL} (which could
* not be reconstructed). The caller must still apply a legacy-prefix
* {@code startsWith} filter in-memory on those NULL rows, since their
* channel can no longer be determined and they are inherently shared across
* channels that collided under the old key8 derivation.
*/
public List<ConversationEntity> listWebchatConversations(String username, Long channelId) {
return conversationMapper.selectList(new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getUsername, username)
.isNull(ConversationEntity::getParentConversationId)
.and(w -> w.eq(ConversationEntity::getChannelId, channelId)
.or().isNull(ConversationEntity::getChannelId))
.orderByDesc(ConversationEntity::getPinned)
.orderByDesc(ConversationEntity::getLastActiveTime));
}

/**
* Create a child conversation (delegation scenario), linking it back to
* its parent via {@code parentConversationId}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ public class ConversationEntity {
*/
private String webchatSessionId;

/**
* WebChat channel that owns this conversation (see V171 migration, #558).
* Lets /sessions filter by channel exactly instead of matching the
* conversationId prefix, which collided across channels (all generated
* apiKeys share the 8-char slice "mc_webch"). Set on creation by webchat
* endpoints; NULL for non-webchat rows and for pre-fix rows whose channel
* can no longer be reconstructed (those remain visible across channels
* until backfilled).
*/
private Long channelId;

/**
* Per-conversation progress notebook JSON (see V100 migration).
* <p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- WebChat channel isolation (#558): persist the channel that owns each webchat
-- conversation so /sessions can filter by channel exactly, instead of matching
-- the conversationId prefix (which collided across channels because all generated
-- apiKeys share the 8-char slice "mc_webch"). NULL for non-webchat rows and for
-- pre-fix webchat rows whose channel can no longer be reconstructed (those remain
-- visible across channels until backfilled by an ops script).
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS channel_id BIGINT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- See the H2 file for context. KingbaseES (PostgreSQL) supports
-- ADD COLUMN IF NOT EXISTS natively.
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS channel_id BIGINT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- See the H2 file for context. MySQL 8.0 doesn't support
-- `ADD COLUMN IF NOT EXISTS`, so the existence check goes through
-- INFORMATION_SCHEMA + a prepared statement.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_conversation'
AND COLUMN_NAME = 'channel_id'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE mate_conversation ADD COLUMN channel_id BIGINT NULL COMMENT ''WebChat channel id for exact channel-scoped /sessions filtering (#558); NULL for non-webchat and pre-fix rows''',
'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ private String tokenFor(String visitorId) {

private String seedPending(String visitorId, String sessionId) {
controller.createSession(API_KEY, req(visitorId, sessionId));
String cid = WebChatController.deriveConversationId(API_KEY, visitorId, sessionId);
String cid = WebChatController.deriveConversationId(CHANNEL_ID, visitorId, sessionId);
// The actor stored on the approval is the webchat username, mirroring
// how chatStream sets it via webchatUsername(visitorId).
String actor = "webchat:" + API_KEY.substring(0, 8) + ":" + visitorId;
Expand All @@ -94,7 +94,7 @@ private String seedPending(String visitorId, String sessionId) {
@DisplayName("deny resolves a pending approval and broadcasts tool_approval_resolved")
void denyResolvesPending() {
String pendingId = seedPending("visitorA", "s1");
String cid = WebChatController.deriveConversationId(API_KEY, "visitorA", "s1");
String cid = WebChatController.deriveConversationId(CHANNEL_ID, "visitorA", "s1");

// Register the stream so the broadcast has a live subscriber state.
streamTracker.register(cid);
Expand Down Expand Up @@ -173,7 +173,7 @@ void denyRejectsCrossVisitorPendingId() {
assertThat(r.getCode()).isEqualTo(404);

// The victim's approval is untouched.
String cidVictim = WebChatController.deriveConversationId(API_KEY, "victimX", "s1");
String cidVictim = WebChatController.deriveConversationId(CHANNEL_ID, "victimX", "s1");
var stillPending = approvalService.getPending(pendingIdVictim);
assertThat(stillPending).as("victim's approval must not be resolved by attacker").isPresent();
assertThat(stillPending.get().getStatus()).isEqualTo("pending");
Expand All @@ -196,7 +196,7 @@ void denyRejectsMismatchedPendingId() {
@DisplayName("stop denies pending approvals on the conversation (approval sweep)")
void stopSweepsPendingApprovals() {
seedPending("visitorF", "s1");
String cid = WebChatController.deriveConversationId(API_KEY, "visitorF", "s1");
String cid = WebChatController.deriveConversationId(CHANNEL_ID, "visitorF", "s1");

// A pending approval exists before stop.
assertThat(approvalService.findPendingByConversation(cid)).isNotNull();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ private String tokenFor(String visitorId) {
@DisplayName("PUT /sessions/pinned flips the column + view reports pinned=1")
void pinFlipsColumn() {
controller.createSession(API_KEY, req("vPin", "s1"));
String cid = WebChatController.deriveConversationId(API_KEY, "vPin", "s1");
String cid = WebChatController.deriveConversationId(CHANNEL_ID, "vPin", "s1");

R<Void> r = controller.pinSession(API_KEY, tokenFor("vPin"), "vPin", "s1",
Map.of("pinned", true));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ void uploadThenStreamAttachesPath() throws Exception {

stream(visitorId, sessionId, "please read the attached", "[\"" + fileId + "\"]");

String cid = WebChatController.deriveConversationId(API_KEY, visitorId, sessionId);
String cid = WebChatController.deriveConversationId(CHANNEL_ID, visitorId, sessionId);
String parts = lastUserContentParts(cid);
assertThat(parts).isNotNull();
// Text part is present.
Expand All @@ -299,7 +299,7 @@ void unknownAttachmentIdDropped() throws Exception {
String visitorId = "vAtt-unknown";
stream(visitorId, null, "hello", "[\"totally-bogus-file-id\"]");

String cid = WebChatController.deriveConversationId(API_KEY, visitorId, null);
String cid = WebChatController.deriveConversationId(CHANNEL_ID, visitorId, null);
String parts = lastUserContentParts(cid);
assertThat(parts).isNotNull();
assertThat(parts).contains("\"type\":\"text\"");
Expand All @@ -320,11 +320,11 @@ void foreignAttachmentIdDropped() throws Exception {
stream(visitorB, null, "trying to grab alice's file", "[\"" + aliceFileId + "\"]");

// B's conversation's user message has no file part.
String bobCid = WebChatController.deriveConversationId(API_KEY, visitorB, null);
String bobCid = WebChatController.deriveConversationId(CHANNEL_ID, visitorB, null);
String bobParts = lastUserContentParts(bobCid);
assertThat(bobParts).doesNotContain("\"type\":\"file\"");
// Alice's conversation is untouched — no user message there at all.
String aliceCid = WebChatController.deriveConversationId(API_KEY, visitorA, null);
String aliceCid = WebChatController.deriveConversationId(CHANNEL_ID, visitorA, null);
Integer aliceMsgCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM mate_message WHERE conversation_id = ? AND role = 'user'",
Integer.class, aliceCid);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package vip.mate.channel.webchat;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import vip.mate.MateClawApplication;
import vip.mate.channel.webchat.WebChatController.WebChatSessionView;
import vip.mate.common.result.R;
import vip.mate.workspace.conversation.ConversationService;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Regression for ISSUE #558: WebChat channel session isolation.
* <p>
* The bug: {@code deriveConversationId} identified a channel by the first 8
* characters of its apiKey. All generated keys share the 11-char prefix
* {@code mc_webchat_}, so the 8-char slice was the constant {@code mc_webch} for
* every channel — two channels with the same visitorId derived the SAME
* conversationId, and their sessions collided / cross-leaked in
* {@code listSessions}.
* <p>
* The fix (V171 {@code channel_id} column) persists the owning channel on each
* webchat row, so {@code listSessions} filters by {@code channel_id} exactly.
* <p>
* Two isolation dimensions are covered:
* <ol>
* <li><b>Channel-scoped rows</b> (channel_id persisted): strictly isolated —
* channel A never sees channel B's sessions. This is the regression that
* the pre-fix code failed.</li>
* <li><b>Pre-fix collided rows</b> (channel_id NULL, shared legacy cid): these
* cannot be split retroactively and remain visible across channels that
* collided. This is an explicitly documented limitation, not a bug.</li>
* </ol>
*/
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:webchat_iso_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789"
})
class WebChatChannelIsolationTest {

private static final String SECRET = "webchat-it-secret-0123456789";
// Both keys start with "mc_webchat_" → identical 8-char slice "mc_webch",
// reproducing the #558 collision under the pre-fix derivation.
private static final String API_KEY_A = "mc_webchat_aaa";
private static final String API_KEY_B = "mc_webchat_bbb";
private static final long CHANNEL_A = 9_155_001L;
private static final long CHANNEL_B = 9_155_002L;
private static final long AGENT_ID = 9_155_011L;
private static final String VISITOR = "sharedVisitor";

@Autowired private WebChatController controller;
@Autowired private ConversationService conversationService;
@Autowired private JdbcTemplate jdbc;

@BeforeEach
void setUp() {
jdbc.update("DELETE FROM mate_channel WHERE id IN (?, ?)", CHANNEL_A, CHANNEL_B);
jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID);
jdbc.update(
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, 'wc-iso-agent', 'react', '', 10, TRUE, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
AGENT_ID);
seedChannel(CHANNEL_A, API_KEY_A);
seedChannel(CHANNEL_B, API_KEY_B);

String owner = WebChatController.webchatUsername(VISITOR);
// Channel-scoped rows (channel_id persisted) — the new, correctly-isolated path.
// Seeded through the channelId-aware overload exactly like createSession/chatStream do.
conversationService.getOrCreateWebchatConversation(
WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "a-s1"),
null, owner, 1L, "a-s1", null, CHANNEL_A);
conversationService.getOrCreateWebchatConversation(
WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "a-s2"),
null, owner, 1L, "a-s2", null, CHANNEL_A);
conversationService.getOrCreateWebchatConversation(
WebChatController.deriveConversationId(CHANNEL_B, VISITOR, "b-s1"),
null, owner, 1L, "b-s1", null, CHANNEL_B);

// One pre-fix collided row: a legacy cid (webchat:mc_webch:...) with channel_id
// left NULL. Both channels derive the SAME legacy cid for this visitor, so this
// single row is shared — it must stay visible to both (cannot be split).
String legacyCid = WebChatController.legacyConversationId(API_KEY_A, VISITOR, "shared-legacy");
conversationService.getOrCreateWebchatConversation(legacyCid, null, owner, 1L, null);
}

private void seedChannel(long id, String apiKey) {
jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"VALUES (?, ?, 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
id, "wc-" + id, AGENT_ID, "{\"api_key\":\"" + apiKey + "\"}");
}

private String tokenFor(long channelId) {
return WebChatController.computeVisitorToken(SECRET, channelId, VISITOR);
}

@Test
@DisplayName("channel-scoped rows are strictly isolated across channels (#558)")
@SuppressWarnings("unchecked")
void channelScopedRowsAreIsolated() {
// Channel A lists only its own channel-scoped sessions + the shared legacy row.
R<List<WebChatSessionView>> ra = (R<List<WebChatSessionView>>) (R<?>)
controller.listSessions(API_KEY_A, tokenFor(CHANNEL_A), VISITOR, false);
assertThat(ra.getCode()).isEqualTo(200);
assertThat(ra.getData()).extracting(WebChatSessionView::getSessionId)
.containsExactlyInAnyOrder("a-s1", "a-s2", "shared-legacy");
// Critically: channel B's b-s1 does NOT leak into channel A.
assertThat(ra.getData()).extracting(WebChatSessionView::getSessionId)
.doesNotContain("b-s1");

// Channel B lists only its own + the same shared legacy row.
R<List<WebChatSessionView>> rb = (R<List<WebChatSessionView>>) (R<?>)
controller.listSessions(API_KEY_B, tokenFor(CHANNEL_B), VISITOR, false);
assertThat(rb.getCode()).isEqualTo(200);
assertThat(rb.getData()).extracting(WebChatSessionView::getSessionId)
.containsExactlyInAnyOrder("b-s1", "shared-legacy");
assertThat(rb.getData()).extracting(WebChatSessionView::getSessionId)
.doesNotContain("a-s1", "a-s2");
}

@Test
@DisplayName("the two channels derive distinct conversationIds for one visitor")
void conversationIdsDifferAcrossChannels() {
// Under the #558 bug both calls returned the SAME id (webchat:mc_webch:...).
String cidA = WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "shared");
String cidB = WebChatController.deriveConversationId(CHANNEL_B, VISITOR, "shared");
assertThat(cidA).isNotEqualTo(cidB);
assertThat(cidA).startsWith("webchat:" + CHANNEL_A + ":");
assertThat(cidB).startsWith("webchat:" + CHANNEL_B + ":");
}

@Test
@DisplayName("persisted channel_id is recorded on creation")
void channelIdIsPersisted() {
// Direct DB check: the channel-scoped rows carry the right channel_id.
Long aChannel = jdbc.queryForObject(
"SELECT channel_id FROM mate_conversation WHERE conversation_id = ?",
Long.class, WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "a-s1"));
assertThat(aChannel).isEqualTo(CHANNEL_A);
Long legacyChannel = jdbc.queryForObject(
"SELECT channel_id FROM mate_conversation WHERE conversation_id = ?",
Long.class,
WebChatController.legacyConversationId(API_KEY_A, VISITOR, "shared-legacy"));
assertThat(legacyChannel).isNull(); // pre-fix row, not backfilled
}
}
Loading