give a member a chat cwd they can actually enter
host captured the first live member turn: uid 1001, nine env vars, zero ANTHROPIC_*, zero POSTGRES_URL, zero JWT_SECRET. The privilege drop and the allowlist both held. One defect. The default chat cwd was DATA_PATH/<email>/general_chat_sessions — a sibling of the member's home, which confineUserTree deliberately makes the platform's at 0700 because the other siblings are attachments and email_accounts. So the turn ran in a directory the member cannot enter, and every Bash call failed on its own cwd. The agent reported its shell as broken, which was true. A member's default is now ~member/general_chat_sessions, created as them through runAs. mkdir -p, so it is idempotent per turn and needs no reprovision. The owner's path does not change, and the sibling stays 0700 — loosening it would trade a broken shell for an open directory holding attachments and mail. 29 said this path is email-derived and therefore stays email-derived. True, and it did not follow that it is usable: an email-derived path under DATA_PATH is precisely the set a member is locked out of. Splitting identity from filesystem path was right; assuming the identity side was inert was not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
# 43 — a member's chat cwd is now inside their home
|
||||||
|
|
||||||
|
Read `42`. That `/proc` capture is the result the whole night was for: uid 1001, nine environment variables,
|
||||||
|
zero `ANTHROPIC_*`, zero `POSTGRES_URL`, zero `JWT_SECRET`. The allowlist and the privilege drop both held
|
||||||
|
under a real turn.
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
A member's default chat cwd is `~member/general_chat_sessions`, created as them via `runAs` — `mkdir -p`, so
|
||||||
|
it is idempotent per turn and needs no reprovision. **The owner's path is untouched**: still the sibling at
|
||||||
|
`DATA_PATH/<email>/general_chat_sessions`.
|
||||||
|
|
||||||
|
The sibling stays 0700 and platform-owned. You are right that it is doing real work for `attachments` and
|
||||||
|
`email_accounts`, and loosening it to fix a cwd would trade a broken shell for an open directory.
|
||||||
|
|
||||||
|
## On `29` getting half of it right
|
||||||
|
|
||||||
|
Worth recording precisely, because the half that was wrong is the interesting one. `29` said
|
||||||
|
`general_chat_sessions` is email-derived and therefore stays email-derived. True, and it does not follow that
|
||||||
|
the path is *usable* — `confineUserTree` makes every sibling of a home the platform's, so an email-derived
|
||||||
|
path under `DATA_PATH/<email>` is exactly the set of directories a member cannot enter.
|
||||||
|
|
||||||
|
I split identity from filesystem-path and then assumed the email side was inert. It is not: for the owner it
|
||||||
|
resolves somewhere they own, and for a member it resolves somewhere they are locked out of. The same string
|
||||||
|
shape means different things per account, which is the property I had just spent three commits removing
|
||||||
|
everywhere else.
|
||||||
|
|
||||||
|
## What to expect on the next turn
|
||||||
|
|
||||||
|
Bash should work. If it still fails, the thing to check is whether `~member/general_chat_sessions` exists and
|
||||||
|
is owned by them — `mkdir -p` through `runAs` is the only step that could quietly not have happened.
|
||||||
|
|
||||||
|
Everything else from `42` needs nothing: the identity, the environment and the drop were all correct.
|
||||||
@@ -19,7 +19,7 @@ import { join } from 'path';
|
|||||||
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
|
import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path';
|
||||||
import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent, getUserById } from 'officerdb';
|
import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent, getUserById } from 'officerdb';
|
||||||
import { resolveHomeDir } from '@@/user-home';
|
import { resolveHomeDir } from '@@/user-home';
|
||||||
import { claudeLoginState } from '@@/os-user-claude';
|
import { claudeLoginState, ensureMemberChatCwd } from '@@/os-user-claude';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
@@ -138,9 +138,16 @@ async function resolveChatCwd(
|
|||||||
email: string,
|
email: string,
|
||||||
userId: number,
|
userId: number,
|
||||||
home: string,
|
home: string,
|
||||||
|
member?: { osUser: string; home: string },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
if (msg.context === 'email') return resolveEmailCwd(userId, email, msg.contextId);
|
||||||
if (msg.context === 'chat') return msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : ensureGeneralChatSessionsCwd(email);
|
if (msg.context === 'chat') {
|
||||||
|
if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd);
|
||||||
|
// The default chat directory. The owner's is a sibling of their home; a member's is INSIDE theirs,
|
||||||
|
// because that sibling is platform-owned at 0700 by design and a member cannot enter it — which is
|
||||||
|
// exactly how the first live member turn failed, with every Bash call dying on its own cwd.
|
||||||
|
return member ? ensureMemberChatCwd(member) : ensureGeneralChatSessionsCwd(email);
|
||||||
|
}
|
||||||
return resolveCwd(home, msg.cwd);
|
return resolveCwd(home, msg.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +402,7 @@ async function handleClaudeCodeChat(
|
|||||||
|
|
||||||
const home = identity.kind === 'member' ? identity.run.home : getOwnerHomeDir(email);
|
const home = identity.kind === 'member' ? identity.run.home : getOwnerHomeDir(email);
|
||||||
|
|
||||||
const cwd = await resolveChatCwd(msg, email, userId, home);
|
const cwd = await resolveChatCwd(msg, email, userId, home, identity.kind === 'member' ? identity.run : undefined);
|
||||||
|
|
||||||
const groupSlug = msg.groupSlug || null;
|
const groupSlug = msg.groupSlug || null;
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,23 @@ export async function provisionClaudeCli(params: { email: string; osUser: string
|
|||||||
return { ok: true, binPath, wrote: true };
|
return { ok: true, binPath, wrote: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A member's own chat-sessions directory, created as them, inside their home.
|
||||||
|
*
|
||||||
|
* The owner's equivalent lives at `DATA_PATH/<email>/general_chat_sessions` — a SIBLING of the home, which
|
||||||
|
* `confineUserTree` deliberately makes the platform's at 0700 because the other siblings are `attachments`
|
||||||
|
* and `email_accounts`. Correct for those, and fatal as a member's cwd: the first live member turn ran there
|
||||||
|
* and every Bash call failed, because the account could not enter its own working directory.
|
||||||
|
*
|
||||||
|
* So a member's goes inside their home instead. Created through `runAs` because the platform cannot mkdir
|
||||||
|
* into a 0700 home it does not own — and `mkdir -p` is idempotent, so this is safe to call per turn.
|
||||||
|
*/
|
||||||
|
export async function ensureMemberChatCwd(params: { osUser: string; home: string }): Promise<string> {
|
||||||
|
const dir = join(params.home, 'general_chat_sessions');
|
||||||
|
await asMember(params.osUser, ['mkdir', '-p', dir]);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
export type ClaudeLoginState = {
|
export type ClaudeLoginState = {
|
||||||
/** The binary is present and executable in their home. */
|
/** The binary is present and executable in their home. */
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user