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)", "Missing relay token in extension settings (chrome.storage.local.relayToken)",
); );
} }
const relayToken = await deriveRelayToken(token, port); // Token is already derived server-side — use it directly
return `ws://${host}:${port}/extension?token=${encodeURIComponent(relayToken)}`; return `ws://${host}:${port}/extension?token=${encodeURIComponent(token)}`;
} }
export function isRetryableReconnectError(err) { 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' import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js'
const DEFAULT_PORT = 18792 const DEFAULT_PORT = 18792
@@ -32,11 +31,11 @@ async function checkRelayReachable(host, port, token) {
return return
} }
try { try {
const relayToken = await deriveRelayToken(trimmedToken, port) // Token is already derived server-side — use it directly
const res = await chrome.runtime.sendMessage({ const res = await chrome.runtime.sendMessage({
type: 'relayCheck', type: 'relayCheck',
url, url,
token: relayToken, token: trimmedToken,
}) })
const result = classifyRelayCheckResponse(res, host, port) const result = classifyRelayCheckResponse(res, host, port)
if (result.action === 'throw') throw new Error(result.error) 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.'); await channel.send('No models available.');
return true; return true;
} }
const current = getSessionModel('discord', discordId); const current = getSessionModel('discord', ctx.userId, discordId);
const grouped = new Map<string, string[]>(); const grouped = new Map<string, string[]>();
for (const m of models) { for (const m of models) {
const list = grouped.get(m.provider) ?? []; const list = grouped.get(m.provider) ?? [];
@@ -170,7 +170,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
} }
if (lower === '!model') { 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.'); await channel.send(current ? `Current model: **${current}**` : 'No active session yet — the default model will be used on your next message.');
return true; return true;
} }
@@ -178,7 +178,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
if (lower.startsWith('!model ')) { if (lower.startsWith('!model ')) {
const requested = content.slice('!model '.length).trim(); const requested = content.slice('!model '.length).trim();
if (!requested) { 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.'); await channel.send(current ? `Current model: **${current}**` : 'No active session yet.');
return true; 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.`); await channel.send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`);
return true; 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.`); await channel.send(`Model switched to **${match.id}**. The new model will be used on your next message.`);
return true; return true;
} }
+16 -11
View File
@@ -39,6 +39,10 @@ const sessionCallbacks = new Map<string, EventCallback>();
// Channel model overrides — survive session eviction/recreation // Channel model overrides — survive session eviction/recreation
const channelModelOverrides = new Map<string, string>(); 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> { async function getUserDefaultModel(userId: number): Promise<string | null> {
try { try {
const settings = await getUserSettings(userId); 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 { export function getSessionModel(context: string, userId: number, contextId: string): string | null {
const sessionId = `channel-${context}-${contextId}`; const sessionId = buildSessionId(context, userId, contextId);
const session = sessionManager.getSession(sessionId); const session = sessionManager.getSession(sessionId);
return session?.model ?? channelModelOverrides.get(sessionId) ?? null; return session?.model ?? channelModelOverrides.get(sessionId) ?? null;
} }
export function setSessionModel(context: string, contextId: string, model: string): void { export function setSessionModel(context: string, userId: number, contextId: string, model: string): void {
const sessionId = `channel-${context}-${contextId}`; const sessionId = buildSessionId(context, userId, contextId);
// Store override independently of session — survives idle eviction // Store override independently of session — survives idle eviction
channelModelOverrides.set(sessionId, model); 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> { export async function sendAndAwait(params: SendAndAwaitParams): Promise<SendAndAwaitResult> {
const { context, contextId } = params; const { userId, context, contextId } = params;
const sessionId = `channel-${context}-${contextId}`; const sessionId = buildSessionId(context, userId, contextId);
// Serialize per session — if two messages arrive at once, second waits for first // Serialize per session — if two messages arrive at once, second waits for first
const existing = sessionLocks.get(sessionId) ?? Promise.resolve(); 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 homeDir = getHomeDir(email);
const cwd = homeDir; 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); 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) { if (!model) {
const userDefault = await getUserDefaultModel(userId); 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); const session = sessionManager.getOrCreate(sessionId, email, cwd, model, null, context, contextId);
session.model = model; session.model = model;
session.meta.model = model; session.meta.model = model;
session.userId = userId; 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) => { return new Promise<SendAndAwaitResult>((resolve, reject) => {
let resultText = ''; 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 // Add user message
+4 -4
View File
@@ -153,7 +153,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
await send('No models available.'); await send('No models available.');
return true; return true;
} }
const current = getSessionModel('telegram', telegramId); const current = getSessionModel('telegram', ctx.userId, telegramId);
const grouped = new Map<string, string[]>(); const grouped = new Map<string, string[]>();
for (const m of models) { for (const m of models) {
const list = grouped.get(m.provider) ?? []; const list = grouped.get(m.provider) ?? [];
@@ -170,7 +170,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
} }
if (lower === '!model') { if (lower === '!model') {
const current = getSessionModel('telegram', telegramId); const current = getSessionModel('telegram', ctx.userId, telegramId);
await send( await send(
current current
? `Current model: *${current}*` ? `Current model: *${current}*`
@@ -182,7 +182,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
if (lower.startsWith('!model ')) { if (lower.startsWith('!model ')) {
const requested = content.slice('!model '.length).trim(); const requested = content.slice('!model '.length).trim();
if (!requested) { if (!requested) {
const current = getSessionModel('telegram', telegramId); const current = getSessionModel('telegram', ctx.userId, telegramId);
await send(current ? `Current model: *${current}*` : 'No active session yet.'); await send(current ? `Current model: *${current}*` : 'No active session yet.');
return true; 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.`); await send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`);
return true; 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.`); await send(`Model switched to *${match.id}*. The new model will be used on your next message.`);
return true; return true;
} }
+13
View File
@@ -1,4 +1,5 @@
import { join } from 'path'; import { join } from 'path';
import { rmSync } from 'node:fs';
import { Client, LocalAuth } from 'whatsapp-web.js'; import { Client, LocalAuth } from 'whatsapp-web.js';
import { getServerIntegration, upsertServerIntegration } from 'officerdb'; import { getServerIntegration, upsertServerIntegration } from 'officerdb';
import { DATA_PATH } from '@@/data-path'; import { DATA_PATH } from '@@/data-path';
@@ -117,6 +118,18 @@ export async function startWhatsAppBotIfConfigured(): Promise<void> {
} }
export async function disconnectWhatsApp(): Promise<void> { export async function disconnectWhatsApp(): Promise<void> {
if (client) {
try {
await client.logout();
} catch {
// May fail if not authenticated
}
}
await stopWhatsAppBot(); 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); await upsertServerIntegration('whatsapp', {}, false);
} }
+4 -4
View File
@@ -154,7 +154,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
await send('No models available.'); await send('No models available.');
return true; return true;
} }
const current = getSessionModel('whatsapp', whatsappId); const current = getSessionModel('whatsapp', ctx.userId, whatsappId);
const grouped = new Map<string, string[]>(); const grouped = new Map<string, string[]>();
for (const m of models) { for (const m of models) {
const list = grouped.get(m.provider) ?? []; const list = grouped.get(m.provider) ?? [];
@@ -171,7 +171,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
} }
if (lower === '!model') { if (lower === '!model') {
const current = getSessionModel('whatsapp', whatsappId); const current = getSessionModel('whatsapp', ctx.userId, whatsappId);
await send( await send(
current current
? `Current model: *${current}*` ? `Current model: *${current}*`
@@ -183,7 +183,7 @@ async function handleCommand(ctx: CommandContext): Promise<boolean> {
if (lower.startsWith('!model ')) { if (lower.startsWith('!model ')) {
const requested = content.slice('!model '.length).trim(); const requested = content.slice('!model '.length).trim();
if (!requested) { if (!requested) {
const current = getSessionModel('whatsapp', whatsappId); const current = getSessionModel('whatsapp', ctx.userId, whatsappId);
await send(current ? `Current model: *${current}*` : 'No active session yet.'); await send(current ? `Current model: *${current}*` : 'No active session yet.');
return true; 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.`); await send(`Model not found: \`${requested}\`\nUse \`!models\` to see available models.`);
return true; 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.`); await send(`Model switched to *${match.id}*. The new model will be used on your next message.`);
return true; return true;
} }