let members use chat
The owner authorized this explicitly. Two refusals removed together, because
they were always one guard in two places: the wholesale isSuperAdmin middleware
in api/chat/chat.ts, and the chat socket's 403 in server.tsx.
They were right for the day they stood. 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.
What replaced them, rather than what deleted them:
the turn runs as the member spawnClaudeAsMember through sudo setpriv,
proven against a real account by 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 from resolveHomeDir
and claude-sessions cannot invent one
the sessions are theirs every session records its owner and all six
sidecar commands refuse a mismatch
Also adds the precondition host asked for in 10: a member whose claude is not
signed in gets the instruction rather than a turn that dies on an auth error and
reads as a broken agent. Not installed and not signed in are separate messages
because they need different actions.
registry.ts and registry.test.ts now describe chat as confined in fact rather
than ahead of its implementation. The comments at both former guards say what
had to exist first, and that a revert should go back to a refusal rather than to
a narrower one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
+3
-8
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user