diff --git a/COMMS/sidecar-app-store/41-gates-are-down.md b/COMMS/sidecar-app-store/41-gates-are-down.md new file mode 100644 index 00000000..718bcce4 --- /dev/null +++ b/COMMS/sidecar-app-store/41-gates-are-down.md @@ -0,0 +1,42 @@ +# 41 — the gates are down + +The owner authorized it explicitly and is signing in as green now. + +## Removed + +- `api/chat/chat.ts` — the wholesale `isSuperAdmin` refusal +- `server.tsx` — the chat socket's 403 + +Together, in one commit, because they were always one guard in two places. `registry.test.ts` and the `chat` +entry in `registry.ts` are updated to describe what is true rather than what was held open. + +Both files keep a comment saying what **replaced** the refusal rather than what deleted it, and naming the four +things that had to exist first: the turn runs as the member, the credential is theirs, the transcripts are +theirs, the sessions record whose they are. Each of those was found wanting at least once by whichever of us +had not written it. The note ends with: if you are reverting this, revert to a refusal, not to a narrower one. + +## Added in the same commit — the `loggedIn` precondition from your `10` + +A member whose `claude` is not signed in would otherwise spawn a turn, have `claude` exit on an auth error, and +see "the agent is broken" — the confusion `/agent-status` exists to prevent, arriving through a different door. +The socket now checks `claudeLoginState` for members and refuses with the instruction itself. + +Two refusals, both member-facing sentences: not installed → ask the owner to reprovision; installed but not +signed in → run `claude` once in a terminal. + +## What to watch, since this is the first time any of it is reachable + +green is signing in now, so the order tonight is: sign-in first, then a turn. If the sign-in has not completed +when they try to chat, the precondition above is what they should see — and that message appearing is itself a +useful result, because it proves the member path is live and the check fires. + +The things I would look at first if a turn misbehaves: + +- **the transcript list** — it should be green's own and empty, not the owner's. That is `ChatIdentity`. +- **the cwd** — `~` should expand to green's home, not the owner's. +- **`/proc` during a turn** — the `claude` process should be uid 1001. +- **no MCP tools** — deliberate: a member gets no MCP config, because the owner's carries their token. Their + agent has no tools until that is designed, and an agent quietly having no tools is worth seeing rather than + discovering. + +`tsgo` clean, 75 pass, 3 skip. `member` is now reachable in production for the first time. diff --git a/src/server.tsx b/src/server.tsx index dec9337d..c41b9a21 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -207,14 +207,9 @@ async function upgradeWs( url.searchParams.delete('osUser'); url.searchParams.delete('home'); - // The other half of the temporary chat gap — see api/chat/chat.ts for the whole reasoning. The capability - // is grantable so the route resolves, but a turn would spawn `claude` as the OWNER, so the transport is - // owner-only until agents run under `runAs`. Refusing the socket is what makes that true rather than - // documented. - if (provider === 'chat') { - const chatUser = await getUserById(user.id); - if (chatUser?.role !== 'Super Admin') return new Response('Forbidden', { status: 403 }); - } + // The chat socket's owner-only refusal was removed on 2026-08-12, with `api/chat/chat.ts`'s in the same + // commit — they were always one guard in two places. A member's turn now runs as their own Linux account + // with their own credential and their own transcripts; the capability check above is what gates it. if (provider === 'terminal') { const resolved = await resolveHomeDir(user.id); diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 39d08fdc..d0ed21ba 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -51,26 +51,25 @@ import { registerAgentPanelRoutes } from './agent-panels-routes'; export const chatRouter = createRouter(); -// ── Chat is grantable, and its machinery is not ready for a member. This is that gap, held open on purpose ── +// ── Chat reached members on 2026-08-12 ── // -// The `chat` capability moved from `execution` to `confined` so the owner can grant it and the route resolves. -// The agent underneath has NOT moved: `claude-manager.ts` drives turns through the Agent SDK, which spawns -// `claude` itself with no way to hand it a uid, and every transcript path here resolves through the owner's -// home. So a member reaching this router would read the owner's session list and run an agent as the owner — -// which is the whole thing the confinement work exists to prevent. +// A wholesale `isSuperAdmin` refusal stood here from the day `chat` became grantable until tonight. It said +// the machinery was not ready, and it was right: a turn spawned `claude` as the OWNER, and every transcript +// path resolved through the owner's home, so a granted member would have read the owner's sessions and run an +// agent as them. // -// Refused wholesale rather than per-route, and reads rather than just writes: `listClaudePwds` returns the -// directory names of the owner's projects, which is not a member's business either. +// What replaced it, rather than what deleted it: // -// What lifts this is per-user agents: the turn becomes its own process under `runAs`, with the member's own -// HOME so `~/.claude` and their transcripts are theirs. docs/per-user-linux-accounts.md § stage 5. Delete this -// middleware then — it is the only thing standing between a granted member and the owner's agent. -chatRouter.use(async (ctx, next) => { - if (!(await isSuperAdmin(ctx.get('user')))) { - throw errors.FORBIDDEN('Chat is not available to members yet — the agent still runs as the server owner.'); - } - return next(); -}); +// - the turn runs as the member — `spawnClaudeAsMember` through `sudo setpriv`, proven against a real +// account by `spawn-as-member.live.test.ts` reading file ownership rather than trusting the process +// - the credential is theirs — `--reset-env` plus an allowlist, so the owner's proxy variables cannot cross +// - the transcripts are theirs — `ChatIdentity` carries a home resolved from `resolveHomeDir`, and this file +// no longer knows how to invent one +// - the sessions are theirs — every session records its owner, and all six sidecar commands refuse a +// mismatch rather than acting on whoever matched +// +// Each of those is a separate commit with its own reasoning, and each was found wanting at least once by a +// reviewer who had not written it. If you are reverting this, revert to a refusal — not to a narrower one. // The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default // general_chat_sessions dir. Claude groups sessions by cwd, so this selects which project group we read. diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index f0bc337b..79d4e442 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -19,6 +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 { mkdirSync } from 'node:fs'; import { logger } from './logger'; @@ -368,6 +369,30 @@ async function handleClaudeCodeChat( return; } + // A member has to sign `claude` in themselves, once, with their own Anthropic account — the platform cannot + // do it for them without lending them the owner's credential, which is the thing this whole feature exists + // to avoid. Without this check their turn spawns, `claude` exits on an auth error, and it surfaces as "the + // agent is broken" — the exact confusion `/agent-status` was built to prevent, arriving through a different + // door. The refusal carries the instruction so the answer is the same whether the UI asked or not. + if (identity.kind === 'member') { + const state = await claudeLoginState({ email, osUser: identity.run.osUser }); + if (!state.installed) { + sendToClient(ws, { + type: 'error', + message: 'Claude is not installed in your home yet — ask the server owner to reprovision your account.', + }); + return; + } + if (!state.loggedIn) { + sendToClient(ws, { + type: 'error', + message: + 'Open a terminal and run `claude` once to sign in with your own Anthropic account. It stays signed in.', + }); + return; + } + } + const home = identity.kind === 'member' ? identity.run.home : getOwnerHomeDir(email); const cwd = await resolveChatCwd(msg, email, userId, home); diff --git a/src/servers/capabilities/registry.test.ts b/src/servers/capabilities/registry.test.ts index cb472a29..b1f4f1fb 100644 --- a/src/servers/capabilities/registry.test.ts +++ b/src/servers/capabilities/registry.test.ts @@ -177,10 +177,10 @@ describe('kinds', () => { // cannot be asserted from the registry, so this pins the inverse: nothing becomes confined without a // deliberate edit here, and the list stays short enough to audit by eye. // - // `chat` is on it ahead of its implementation, deliberately and temporarily: the capability is grantable so - // the route resolves, while api/chat/chat.ts and the chat socket both refuse a non-owner because a turn - // would still spawn the agent as the owner. If you are here because this test failed, check that those two - // guards moved together with whatever you changed. + // `chat` was on this list ahead of its implementation for a day, with `api/chat/chat.ts` and the chat socket + // both refusing non-owners because a turn still spawned the agent as the owner. Both refusals were removed + // on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential, + // transcripts and session ownership. `chat` is now confined in fact and not only in the registry. test('confined is a short, deliberate list', () => { expect(CAPABILITIES.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']); }); diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts index 788c82d1..e9d2508e 100644 --- a/src/servers/capabilities/registry.ts +++ b/src/servers/capabilities/registry.ts @@ -272,11 +272,10 @@ export const CAPABILITIES: Capability[] = [ ws: ['terminal'], routes: ['/terminal'], }, - // Confined so the owner can grant it and the route resolves — but the agent underneath still runs as the - // OWNER, so `api/chat/chat.ts` refuses a non-owner outright and the chat socket is refused in server.tsx. - // A deliberate, temporary gap: the permission exists, the functionality follows when a turn can be spawned - // under `runAs` with the member's own HOME. Until then this grant buys a route and a refusal, and the - // comments at both guards say so. + // Confined, and true since 2026-08-12: a member's turn spawns their own `claude` as their own Linux account + // through `sudo setpriv`, with their own `~/.claude` credential, their own transcripts and sessions that + // record whose they are. The two owner-only refusals that held this open — `api/chat/chat.ts` and the socket + // in `server.tsx` — were removed together once each of those was in place. { key: 'chat', label: 'Chat',