diff --git a/COMMS/sidecar-app-store/43-member-chat-cwd.md b/COMMS/sidecar-app-store/43-member-chat-cwd.md new file mode 100644 index 00000000..14fd608c --- /dev/null +++ b/COMMS/sidecar-app-store/43-member-chat-cwd.md @@ -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//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/` 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. diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 79d4e442..1308a8b5 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -19,7 +19,7 @@ import { join } from 'path'; import { getOwnerHomeDir, getEmailAccountsDir } from '../../../servers/data-path'; import { getUserSettings, getEmailAccounts, getChatEventsSince, appendChatEvent, getUserById } from 'officerdb'; import { resolveHomeDir } from '@@/user-home'; -import { claudeLoginState } from '@@/os-user-claude'; +import { claudeLoginState, ensureMemberChatCwd } from '@@/os-user-claude'; import { mkdirSync } from 'node:fs'; import { logger } from './logger'; @@ -138,9 +138,16 @@ async function resolveChatCwd( email: string, userId: number, home: string, + member?: { osUser: string; home: string }, ): Promise { 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); } @@ -395,7 +402,7 @@ async function handleClaudeCodeChat( 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; diff --git a/src/servers/os-user-claude.ts b/src/servers/os-user-claude.ts index b2d681a1..2203672f 100644 --- a/src/servers/os-user-claude.ts +++ b/src/servers/os-user-claude.ts @@ -123,6 +123,23 @@ export async function provisionClaudeCli(params: { email: string; osUser: string 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//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 { + const dir = join(params.home, 'general_chat_sessions'); + await asMember(params.osUser, ['mkdir', '-p', dir]); + return dir; +} + export type ClaudeLoginState = { /** The binary is present and executable in their home. */ installed: boolean;