diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index cb6f9acf..d3942fa6 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -18,6 +18,7 @@ import { join } from 'path'; import { getOwnerHomeDir, getEmailAccountsDir, + getPluginChatsDir, findEmailAccountName, getCommanderDir, } from '../../../servers/data-path'; @@ -263,6 +264,27 @@ async function resolveChatCwd( } return dir; } + /* + A plugin's own chats run in a directory the plugin owns — `$DATA_PATH//plugins//chats`. + + `contextId` is the plugin's appName and is the only thing taken from the client here. The PATH is built + server-side from it, never sent: `docs/commander-directory.md` reversed exactly that call for + commanders, and the reasoning carries — a client that can name a directory can name any directory, and + `getPluginChatsDir` refuses a separator for the same reason `getCommanderDir` does. + + Why a directory rather than more preamble: Claude Code reads a `CLAUDE.md` from its cwd at session + start, so a plugin can give its chats context that costs nothing in every other conversation on the + machine. It also keeps a plugin's chats off the account's home. + + No `contextId` means no plugin, which falls through to the ordinary answer rather than inventing a + directory named after nothing. + */ + if (msg.context === 'plugin' && msg.contextId) { + const dir = getPluginChatsDir(email, msg.contextId); + mkdirSync(dir, { recursive: true }); + return dir; + } + if (msg.context === 'email') return resolveEmailCwd(email, msg.contextId); if (msg.context === 'chat') { if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd); diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts index d4e526ff..8f46ba1a 100644 --- a/src/servers/data-path.ts +++ b/src/servers/data-path.ts @@ -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//plugins//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//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 diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index f690c08a..f865eb66 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -2,7 +2,7 @@ import { chmod, mkdir, readdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { secretStorePath } from 'officerdb/secret-store'; -import { DATA_PATH, USER_DIRS, getOwnerHomeDir, getCommandersDir, toShellUsername } from './data-path'; +import { DATA_PATH, USER_DIRS, getOwnerHomeDir, getCommandersDir, getPluginsDir, toShellUsername } from './data-path'; // Real Linux accounts for members, so the surfaces that execute code can run as them. // @@ -337,13 +337,17 @@ export async function confineUserTree(params: { }; } - // Every sibling of `home` is the platform's, confined 700 to the service user — EXCEPT `commanders`, - // which gets the same member ownership as `home` itself, just below. Both are excluded from this loop - // for the same reason: this pass runs unconditionally on every provision, and re-confining a directory - // a member is meant to reach would silently undo the block that grants it. + // Every sibling of `home` is the platform's, confined 700 to the service user — EXCEPT `commanders` + // and `plugins`, which get the same member ownership as `home` itself, just below. All are excluded + // from this loop for the same reason: this pass runs unconditionally on every provision, and + // re-confining a directory a member is meant to reach would silently undo the block that grants it. + // + // `plugins` holds each plugin's chat working directories (`getPluginChatsDir`). A member's chat runs + // THERE, so leaving it 700-service-user repeats the `general_chat_sessions` failure exactly: the turn + // dies on its own cwd before running anything, and it reads as the agent being broken. const entries = await readdir(accountDir, { withFileTypes: true }); for (const entry of entries) { - if (entry.name === 'home' || entry.name === 'commanders') continue; + if (entry.name === 'home' || entry.name === 'commanders' || entry.name === 'plugins') continue; if (!entry.isDirectory()) continue; await chmod(join(accountDir, entry.name), 0o700); } @@ -430,6 +434,22 @@ export async function confineUserTree(params: { const closeCommanders = await run(['sudo', '-n', 'chmod', '700', commandersDir]); if (!closeCommanders.ok) return { ok: false, error: `chmod of ${commandersDir} failed: ${closeCommanders.out}` }; + // ── And the same for `plugins`, for the same reason ── + // + // A plugin's chats run in `$DATA_PATH//plugins//chats` (`getPluginChatsDir`), which is + // a sibling of `home` exactly as `commanders` is, and a member's turn there runs as the MEMBER. So it + // needs their ownership or it is the `general_chat_sessions` failure a third time — a working directory + // the turn cannot `cd` into, presenting as an agent broken on its first command. + // + // Recursive, because plugins create their own subdirectories under here and a directory made by the + // platform process would otherwise belong to the service user inside a tree the member owns. + const pluginsDir = getPluginsDir(params.email); + if (!existsSync(pluginsDir)) await mkdir(pluginsDir, { recursive: true }); + const givePlugins = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, pluginsDir]); + if (!givePlugins.ok) return { ok: false, error: `chown of ${pluginsDir} failed: ${givePlugins.out}` }; + const closePlugins = await run(['sudo', '-n', 'chmod', '700', pluginsDir]); + if (!closePlugins.ok) return { ok: false, error: `chmod of ${pluginsDir} failed: ${closePlugins.out}` }; + // ── One directory where container bind mounts can live ── // // Created for every account, not only the ones that get a daemon: it is two `install` calls, and