From 367006fb5d4de71abeb5b59ee8fdb480e45524f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 18:25:25 +0000 Subject: [PATCH] a plugin can ask for its own chats back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a plugin's chats run in $DATA_PATH//plugins//chats, built server-side and deliberately never sent to the browser. which left the plugin able to START conversations in its own directory and with no way to LIST them — the path is the one thing it cannot name. so /chat/sessions takes ?context=plugin&contextId= and resolves it the same way the turn does. the four call sites already shared cwdOf; it takes the identity now rather than just the home. the plain ?cwd= form stays for /chat, where the user picks a directory and knows what they picked. it is not a hole either way: listClaudeSessions reads that identity's own ~/.claude/projects, so naming another directory shows you your own conversations elsewhere, never anybody else's. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/api/chat/chat.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index e28f428f..ddd51d69 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -1,3 +1,4 @@ +import { getPluginChatsDir } from '@@/data-path'; import type { Context } from 'hono'; import { emitToSession, sessionKeyForTranscript } from './websocket'; import { pauseBackgroundTask, restartBackgroundTask } from './task-restart'; @@ -108,7 +109,24 @@ export const chatRouter = createRouter(); // caller's own home. Claude groups sessions by cwd, so this selects which project group we read. // OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd // selects there too. -const cwdOf = (ctx: Context, home: string): string => ctx.req.query('cwd')?.trim() || home; +/** + * Which directory a listing is about. + * + * `?context=plugin&contextId=` resolves server-side, and it exists because the client CANNOT + * name this one: a plugin's chats run in `$DATA_PATH//plugins//chats`, built by + * `resolveChatCwd` and deliberately never sent to the browser. Without this, a plugin can start chats in + * its own directory and then has no way to ask for them back. + * + * The plain `?cwd=` form stays for `/chat`, where the user picks a directory and knows what they picked. + * It is not a hole — `listClaudeSessions` reads that identity's own `~/.claude/projects`, so naming + * another directory shows you your own conversations elsewhere, never anybody else's. + */ +const cwdOf = (ctx: Context, who: { home: string; email: string }): string => { + const context = ctx.req.query('context'); + const contextId = ctx.req.query('contextId')?.trim(); + if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId); + return ctx.req.query('cwd')?.trim() || who.home; +}; // GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions. chatRouter.get('/pwds', async (ctx) => { @@ -121,7 +139,7 @@ chatRouter.get('/pwds', async (ctx) => { chatRouter.get('/sessions', async (ctx) => { const user = ctx.get('user'); const who = await chatIdentity(user); - const cwd = cwdOf(ctx, who.home); + const cwd = cwdOf(ctx, who); // `?all=1` — every directory at once, for the list's Latest tab. A separate parameter rather than a // separate route because the response shape is identical and every consumer downstream (rename, delete, @@ -205,7 +223,7 @@ chatRouter.get('/sessions/:id', async (ctx) => { const user = ctx.get('user'); const who = await chatIdentity(user); const id = ctx.req.param('id'); - const cwd = cwdOf(ctx, who.home); + const cwd = cwdOf(ctx, who); // Fall back to a by-id scan when the (default) cwd doesn't hold it — a fresh /chat/ deep-link/refresh // doesn't know the session's cwd. The returned detail carries the real cwd for the client to scope the UI. // 404 rather than 403 on the OpenCode branch: a non-owner has no way to tell a session they may not read @@ -418,7 +436,7 @@ chatRouter.get('/live', async (ctx) => { chatRouter.delete('/sessions/:id', async (ctx) => { const who = await chatIdentity(ctx.get('user')); const id = ctx.req.param('id'); - const cwd = cwdOf(ctx, who.home); + const cwd = cwdOf(ctx, who); if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404); if (isOpenCodeSessionId(id)) { if (!(await deleteOpenCodeSession(id))) return ctx.text('Not found', 404); @@ -457,7 +475,7 @@ chatRouter.delete('/sessions/:id', async (ctx) => { chatRouter.patch('/sessions/:id/title', async (ctx) => { const who = await chatIdentity(ctx.get('user')); const id = ctx.req.param('id'); - const cwd = cwdOf(ctx, who.home); + const cwd = cwdOf(ctx, who); const { title } = await ctx.req.json<{ title?: string }>(); if (!title?.trim()) return ctx.text('title is required', 400); if (isOpenCodeSessionId(id) && !who.isOwner) return ctx.text('Not found', 404);