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:
@@ -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/<email>/plugins/<appName>/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);
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-6
@@ -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/<email>/plugins/<appName>/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
|
||||
|
||||
Reference in New Issue
Block a user