user-scoped channel sessions, whatsapp disconnect cleanup, browser relay token fix

Channel session IDs now include userId (channel-{provider}-{userId}-{contextId})
to prevent cross-user contamination in multi-user setups. WhatsApp disconnect
properly logs out and clears cached auth. Browser relay uses server-derived token
directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:14:47 +00:00
co-authored by Claude Opus 4.6
parent 209d58a525
commit 8f1963fedb
7 changed files with 45 additions and 28 deletions
@@ -38,8 +38,8 @@ export async function buildRelayWsUrl(host, port, gatewayToken) {
"Missing relay token in extension settings (chrome.storage.local.relayToken)",
);
}
const relayToken = await deriveRelayToken(token, port);
return `ws://${host}:${port}/extension?token=${encodeURIComponent(relayToken)}`;
// Token is already derived server-side — use it directly
return `ws://${host}:${port}/extension?token=${encodeURIComponent(token)}`;
}
export function isRetryableReconnectError(err) {
+2 -3
View File
@@ -1,4 +1,3 @@
import { deriveRelayToken } from './background-utils.js'
import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js'
const DEFAULT_PORT = 18792
@@ -32,11 +31,11 @@ async function checkRelayReachable(host, port, token) {
return
}
try {
const relayToken = await deriveRelayToken(trimmedToken, port)
// Token is already derived server-side — use it directly
const res = await chrome.runtime.sendMessage({
type: 'relayCheck',
url,
token: relayToken,
token: trimmedToken,
})
const result = classifyRelayCheckResponse(res, host, port)
if (result.action === 'throw') throw new Error(result.error)
+4 -4
View File
@@ -153,7 +153,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
await channel.send('No models available.');
return true;
}
const current = getSessionModel('discord', discordId);
const current = getSessionModel('discord', ctx.userId, discordId);
const grouped = new Map<string, string[]>();
for (const m of models) {
const list = grouped.get(m.provider) ?? [];
@@ -170,7 +170,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
}
if (lower === '!model') {
const current = getSessionModel('discord', discordId);
const current = getSessionModel('discord', ctx.userId, discordId);
await channel.send(current ? `Current model: **${current}**` : 'No active session yet — the default model will be used on your next message.');
return true;
}
@@ -178,7 +178,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
if (lower.startsWith('!model ')) {
const requested = content.slice('!model '.length).trim();
if (!requested) {
const current = getSessionModel('discord', discordId);
const current = getSessionModel('discord', ctx.userId, discordId);
await channel.send(current ? `Current model: **${current}**` : 'No active session yet.');
return true;
}
@@ -188,7 +188,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
await channel.send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`);
return true;
}
setSessionModel('discord', discordId, match.id);
setSessionModel('discord', ctx.userId, discordId, match.id);
await channel.send(`Model switched to **${match.id}**. The new model will be used on your next message.`);
return true;
}
+16 -11
View File
@@ -39,6 +39,10 @@ const sessionCallbacks = new Map<string, EventCallback>();
// Channel model overrides — survive session eviction/recreation
const channelModelOverrides = new Map<string, string>();
function buildSessionId(context: string, userId: number, contextId: string): string {
return `channel-${context}-${userId}-${contextId}`;
}
async function getUserDefaultModel(userId: number): Promise<string | null> {
try {
const settings = await getUserSettings(userId);
@@ -49,14 +53,14 @@ async function getUserDefaultModel(userId: number): Promise<string | null> {
}
}
export function getSessionModel(context: string, contextId: string): string | null {
const sessionId = `channel-${context}-${contextId}`;
export function getSessionModel(context: string, userId: number, contextId: string): string | null {
const sessionId = buildSessionId(context, userId, contextId);
const session = sessionManager.getSession(sessionId);
return session?.model ?? channelModelOverrides.get(sessionId) ?? null;
}
export function setSessionModel(context: string, contextId: string, model: string): void {
const sessionId = `channel-${context}-${contextId}`;
export function setSessionModel(context: string, userId: number, contextId: string, model: string): void {
const sessionId = buildSessionId(context, userId, contextId);
// Store override independently of session — survives idle eviction
channelModelOverrides.set(sessionId, model);
@@ -76,8 +80,8 @@ export function setSessionModel(context: string, contextId: string, model: strin
}
export async function sendAndAwait(params: SendAndAwaitParams): Promise<SendAndAwaitResult> {
const { context, contextId } = params;
const sessionId = `channel-${context}-${contextId}`;
const { userId, context, contextId } = params;
const sessionId = buildSessionId(context, userId, contextId);
// Serialize per session — if two messages arrive at once, second waits for first
const existing = sessionLocks.get(sessionId) ?? Promise.resolve();
@@ -112,19 +116,20 @@ async function doSend(sessionId: string, params: SendAndAwaitParams): Promise<Se
const homeDir = getHomeDir(email);
const cwd = homeDir;
// Resolve model: explicit param > channel override > existing session model > user default > system default
// Resolve model: explicit param > !model override > user default > existing session > system default
const existingSession = sessionManager.getSession(sessionId);
let model = params.model ?? channelModelOverrides.get(sessionId) ?? existingSession?.model;
const override = channelModelOverrides.get(sessionId);
let model = params.model ?? override;
if (!model) {
const userDefault = await getUserDefaultModel(userId);
model = userDefault ?? DEFAULT_MODEL;
model = userDefault ?? existingSession?.model ?? DEFAULT_MODEL;
}
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, null, context, contextId);
session.model = model;
session.meta.model = model;
session.userId = userId;
logger.info('Channel doSend', { sessionId, model, hasProcess: !!session.piProcess });
logger.info('Channel doSend', { sessionId, model, hasProcess: !!session.piProcess, userId, email });
return new Promise<SendAndAwaitResult>((resolve, reject) => {
let resultText = '';
@@ -296,7 +301,7 @@ async function doSend(sessionId: string, params: SendAndAwaitParams): Promise<Se
}
});
logger.info('Spawned Pi for channel session', { sessionId, model, context });
logger.info('Spawned Pi for channel session', { sessionId, model, context, userId, email });
}
// Add user message
+4 -4
View File
@@ -153,7 +153,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
await send('No models available.');
return true;
}
const current = getSessionModel('telegram', telegramId);
const current = getSessionModel('telegram', ctx.userId, telegramId);
const grouped = new Map<string, string[]>();
for (const m of models) {
const list = grouped.get(m.provider) ?? [];
@@ -170,7 +170,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
}
if (lower === '!model') {
const current = getSessionModel('telegram', telegramId);
const current = getSessionModel('telegram', ctx.userId, telegramId);
await send(
current
? `Current model: *${current}*`
@@ -182,7 +182,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
if (lower.startsWith('!model ')) {
const requested = content.slice('!model '.length).trim();
if (!requested) {
const current = getSessionModel('telegram', telegramId);
const current = getSessionModel('telegram', ctx.userId, telegramId);
await send(current ? `Current model: *${current}*` : 'No active session yet.');
return true;
}
@@ -192,7 +192,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
await send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`);
return true;
}
setSessionModel('telegram', telegramId, match.id);
setSessionModel('telegram', ctx.userId, telegramId, match.id);
await send(`Model switched to *${match.id}*. The new model will be used on your next message.`);
return true;
}
+13
View File
@@ -1,4 +1,5 @@
import { join } from 'path';
import { rmSync } from 'node:fs';
import { Client, LocalAuth } from 'whatsapp-web.js';
import { getServerIntegration, upsertServerIntegration } from 'officerdb';
import { DATA_PATH } from '@@/data-path';
@@ -117,6 +118,18 @@ export async function startWhatsAppBotIfConfigured(): Promise<void> {
}
export async function disconnectWhatsApp(): Promise<void> {
if (client) {
try {
await client.logout();
} catch {
// May fail if not authenticated
}
}
await stopWhatsAppBot();
// Remove cached session so a new QR is shown on next connect
const authPath = join(DATA_PATH, '.wwebjs_auth');
rmSync(authPath, { recursive: true, force: true });
await upsertServerIntegration('whatsapp', {}, false);
}
+4 -4
View File
@@ -154,7 +154,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
await send('No models available.');
return true;
}
const current = getSessionModel('whatsapp', whatsappId);
const current = getSessionModel('whatsapp', ctx.userId, whatsappId);
const grouped = new Map<string, string[]>();
for (const m of models) {
const list = grouped.get(m.provider) ?? [];
@@ -171,7 +171,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
}
if (lower === '!model') {
const current = getSessionModel('whatsapp', whatsappId);
const current = getSessionModel('whatsapp', ctx.userId, whatsappId);
await send(
current
? `Current model: *${current}*`
@@ -183,7 +183,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
if (lower.startsWith('!model ')) {
const requested = content.slice('!model '.length).trim();
if (!requested) {
const current = getSessionModel('whatsapp', whatsappId);
const current = getSessionModel('whatsapp', ctx.userId, whatsappId);
await send(current ? `Current model: *${current}*` : 'No active session yet.');
return true;
}
@@ -193,7 +193,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
await send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`);
return true;
}
setSessionModel('whatsapp', whatsappId, match.id);
setSessionModel('whatsapp', ctx.userId, whatsappId, match.id);
await send(`Model switched to *${match.id}*. The new model will be used on your next message.`);
return true;
}