a plugin's chats get a directory of their own

$DATA_PATH/<email>/plugins/<appName>/chats, resolved server-side from a
context the client names.

why a directory: claude code reads a CLAUDE.md from its cwd at session start,
which is the entire mechanism behind docs/commander-directory.md. a plugin that
wants its chats to know something specific writes it there once instead of
paying for it in every preamble on the machine. it also keeps those chats off
the account's home, where they were sitting on top of everything its owner has.

the client sends context='plugin' and contextId=<appName> and NOT a path.
commander-directory.md reversed exactly that call and the reasoning carries: a
client that can name a directory can name any directory. getPluginChatsDir
refuses a separator, same guard as getCommanderDir.

and the part that would have broken quietly: confineUserTree chmods every
sibling of home to 0700 owned by the service user, carving out only
`commanders`. `plugins` now joins it and takes the same member ownership.
without that this is the general_chat_sessions failure a third time — a
member's turn dying on its own working directory before running anything, which
reads as the agent being broken rather than as a permission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-25 18:18:11 +00:00
co-authored by Claude Opus 5
parent 0ef1bbc3c4
commit cb41a8fe16
3 changed files with 83 additions and 6 deletions
+35
View File
@@ -235,6 +235,41 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
*/
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
/**
* Where a plugin's own chats run — `$DATA_PATH/<email>/plugins/<appName>/chats`.
*
* ── Why a directory at all ──
*
* A chat's working directory is the platform's proven way to give a conversation context it did not have
* to be told: Claude Code reads a `CLAUDE.md` from its cwd at session start, which is the whole mechanism
* behind `docs/commander-directory.md`. A plugin that wants its chats to know something specific writes it
* there once, rather than paying for it in every preamble on the machine.
*
* It also keeps those chats out of the account's home, so a plugin's conversation is not casually sitting
* on top of everything its owner has.
*
* ── Why it is a SIBLING of home, and what that costs ──
*
* `confineUserTree` chmods every sibling of `home` to 0700 owned by the service user, with `commanders`
* carved out because a member has to reach it. **`plugins` is carved out the same way, and must stay that
* way.** Without it this repeats a failure already on record: `/chat` once ran in
* `DATA_PATH/<email>/general_chat_sessions`, and the first live member turn died on its own working
* directory before doing anything, because a sibling of the home is the platform's by default.
*
* ── `appName` is not a path ──
*
* Same guard as `getCommanderDir`, for the same reason: this value reaches here from a plugin manifest and
* a client message, and a separator in it would resolve outside the account's tree entirely.
*/
export const getPluginsDir = (email: string) => join(DATA_PATH, email, 'plugins');
export const getPluginChatsDir = (email: string, appName: string): string => {
if (!appName || appName.includes('/') || appName.includes('\\') || appName === '.' || appName === '..') {
throw new Error(`refusing to resolve a plugin directory for ${JSON.stringify(appName)}`);
}
return join(getPluginsDir(email), appName, 'chats');
};
/**
* One commander's own directory — `CLAUDE.md`, `state/`, and whatever it writes for itself. `dirName` is
* `command_hq_sessions.dir_name`, derived from the row's own serial id at creation. See