command hq: the web creation path, and a member could not have used it

`context: 'command-hq'` on a chat websocket turn now resolves to
getCommandersDir(email) regardless of any client cwd, and the row gets
written the moment a real transcript id is known — the same
session:claude/result seed chat_session_models already waits on, a sibling
closure (fileAsCommander) hung off the same two triggers.

found while wiring it, not guessed at: a member's commander turn would have
failed to spawn at all. getCommandersDir is a sibling of home, and
confineUserTree confines every sibling of home to 700 owned by the service
user — unreachable "even by name" — which is exactly wrong for a directory a
member's own turn needs to cd into via setpriv. same shape of bug
resolveChatCwd already tells a story about (general_chat_sessions,
abandoned for the same reason). fixed by chowning commanders/ to the member
the same way confineUserTree already chowns home, and excluding it from the
loop that confines every other sibling. no ACL needed, unlike home — command
hq reads a member's transcripts through listTranscriptsAs, the same
as-the-member read /chat already uses, never as the service user directly.

the gap this leaves: an account provisioned before this commit needs one
manual reprovision (provision-linux-route.ts / manage-users.ts, already
idempotent) before its first commander turn works. documented, not silently
left.

pulled COMMS/command-hq/{mobile,watchers-what-broke}.md from the mobile
side's session — apps/commandhq already exists and typechecks against the
router. their finding: mobile cannot create a commander the way the web
composer does (a context flag two independent clients would have to carry
correctly), and decided with the owner on POST /command-hq/commanders/new
instead. not built server-side yet — the real blocker is that claude does
not assign a transcript id until a turn actually runs, so the endpoint has
to run one and wait for session:claude, which means extracting the
turn-starting half of handleClaudeChat rather than a second copy of it.
replied in COMMS/command-hq/platform.md with the two open questions before
building it (prompt omitted, and wanting a second look at that refactor).

docs/command-hq.md updated throughout to say plainly what's built vs planned
per path (web vs mobile) rather than one blended status.

bunx tsgo clean. suite at baseline (same 7 pre-existing failures).
confineUserTree has no unit tests today (sudo/uid-dependent, matching this
file's existing coverage posture) — not inventing a new harness for one
block inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 06:58:23 +00:00
co-authored by Claude Opus 5
parent ff88c93b80
commit e9b1ce5e94
5 changed files with 236 additions and 30 deletions
+66 -4
View File
@@ -15,7 +15,12 @@ import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
import * as sidecar from '@@/sidecar-registry';
import { join } from 'path';
import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path';
import {
getOwnerHomeDir,
getEmailAccountsDir,
findEmailAccountName,
getCommandersDir,
} from '../../../servers/data-path';
import { attachedFilePaths } from './attached-files';
import {
getUserSettings,
@@ -24,6 +29,7 @@ import {
getUserById,
setChatSessionModel,
setChatSessionPlanMode,
createCommandHqSession,
} from 'officerdb';
import { resolveHomeDir } from '@@/user-home';
import { claudeLoginState } from '@@/os-user-claude';
@@ -143,6 +149,19 @@ async function resolveChatCwd(
userId: number,
home: string,
): Promise<string> {
// Every commander a user has shares ONE directory — never a client-chosen one, and never per-commander
// (see `getCommandersDir`). `msg.cwd` is ignored on this branch entirely, the same posture the identity
// half of a turn already takes: resolved by the platform, never trusted from the caller.
//
// `mkdirSync` — synchronous, so this returns only once the directory genuinely exists, the same pattern
// `resolveEmailCwd` below already uses. No race: unlike the rejected per-commander design, this directory
// is shared and only the very first commander ever needs the mkdir to do anything; `recursive: true`
// makes every call after that a no-op stat.
if (msg.context === 'command-hq') {
const dir = getCommandersDir(email);
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);
@@ -319,7 +338,13 @@ function foldIntoSession(session: UserSession, msg: TurnMessage, model: string):
}
// Shared by both harnesses now: whichever sidecar ran the turn has already translated and committed it.
function createMessageHandler(sessionId: string, model: string, resumeSessionId?: string, planMode?: boolean) {
function createMessageHandler(
sessionId: string,
model: string,
resumeSessionId?: string,
planMode?: boolean,
commandHq?: boolean,
) {
/*
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
@@ -403,6 +428,34 @@ function createMessageHandler(sessionId: string, model: string, resumeSessionId?
});
};
/*
File this session as a commander, once the transcript id is known — the same wait `record` above is
built around, and for the same reason: there is nothing to key the row on before the harness announces
it. Gated on `commandHq`, which the caller sets only when `msg.context === 'command-hq'`, so an
ordinary `/chat` turn never touches this table.
Idempotent by construction (`createCommandHqSession` upserts on `(user_id, session_id)`), so calling it
again on every later turn of the same commander — which happens, because the client keeps sending
`context: 'command-hq'` for `resolveChatCwd` to keep resolving the right directory — costs one no-op
upsert rather than a duplicate row. That repetition is also a cheap self-heal: if the row were ever lost,
the next turn puts it back.
Fire-and-forget, swallowed failure, same posture as `record`: a database hiccup here must not be a
reason a chat turn fails.
*/
const fileAsCommander = (): void => {
if (!commandHq || !transcriptId) return;
const userId = sessionManager.getSession(sessionId)?.userId;
if (!userId) return;
void createCommandHqSession(userId, transcriptId).catch((err: unknown) => {
logger.warn('Could not file this session as a commander', {
sessionId,
transcriptId,
error: err instanceof Error ? err.message : String(err),
});
});
};
return (msg: TurnMessage, seq?: number): void => {
const session = sessionManager.getSession(sessionId);
if (!session) return;
@@ -452,10 +505,12 @@ function createMessageHandler(sessionId: string, model: string, resumeSessionId?
if (msg.type === 'session:claude' && msg.claudeSessionId) {
transcriptId = msg.claudeSessionId;
record();
fileAsCommander();
}
if (msg.type === 'result' && msg.claudeSessionId && msg.claudeSessionId !== transcriptId) {
transcriptId = msg.claudeSessionId;
record();
fileAsCommander();
}
/*
@@ -723,8 +778,15 @@ async function handleClaudeCodeChat(
session.isGenerating = true;
// The resume id is passed so a tier switch on a RESUMED conversation is recorded when it happens rather
// than when the turn ends — see the seed in `createMessageHandler`.
const onMessage = createMessageHandler(sessionId, model, msg.resumeSessionId, msg.planMode);
// than when the turn ends — see the seed in `createMessageHandler`. `commandHq` is `msg.context ===
// 'command-hq'`, the same signal `resolveChatCwd` above already reads to pick this session's directory.
const onMessage = createMessageHandler(
sessionId,
model,
msg.resumeSessionId,
msg.planMode,
msg.context === 'command-hq',
);
/**
* One description of the turn, used by both paths below.
+7
View File
@@ -209,6 +209,13 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
* normal chat runs in `getOwnerHomeDir` (their real login home). Commanders use the managed directory
* instead, on both sides of the account split, which is what keeps them out of the owner's own project
* transcripts as surely as `command_hq_sessions` keeps them out of the `/chat` list. See `docs/command-hq.md`.
*
* **A sibling of `home`, but reachable like `home` — not confined like every other sibling.** Every other
* directory alongside a member's `home` (`attachments`, `email_accounts`, …) is 700 and owned by the
* SERVICE user, unreachable by the member "even by name" — correct for platform-written data, and exactly
* wrong for a directory a member's own `claude` must `cd` into via `setpriv`. `confineUserTree`
* (`os-user.ts`) chowns this one to the member the same way it chowns `home`. An account provisioned
* BEFORE this existed will not have that chown until it is re-provisioned once — see `docs/command-hq.md`.
*/
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
+29 -3
View File
@@ -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, toShellUsername } from './data-path';
import { DATA_PATH, USER_DIRS, getOwnerHomeDir, getCommandersDir, toShellUsername } from './data-path';
// Real Linux accounts for members, so the surfaces that execute code can run as them.
//
@@ -337,10 +337,13 @@ export async function confineUserTree(params: {
};
}
// Every sibling of `home` is the platform's. 700 means traversal alone does not open them.
// 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.
const entries = await readdir(accountDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === 'home') continue;
if (entry.name === 'home' || entry.name === 'commanders') continue;
if (!entry.isDirectory()) continue;
await chmod(join(accountDir, entry.name), 0o700);
}
@@ -404,6 +407,29 @@ export async function confineUserTree(params: {
}
}
// ── Commanders: a second directory the member must be able to reach directly ──
//
// Command HQ (docs/command-hq.md) gives every account ONE shared directory for the long-running jobs
// ("commanders") they start there — `getCommandersDir`, a sibling of `home` rather than something
// inside it, on purpose: keeping it out of the owner's own home is the whole reason it exists as its
// own directory rather than reusing `home`. But every OTHER sibling of `home` is confined 700 to the
// service user, unreachable "even by name" — and a member's commander turn runs as the MEMBER'S own
// Linux account via `setpriv`, so a sibling with that shape is exactly the `general_chat_sessions`
// mistake `resolveChatCwd` already tells this story about: a working directory the turn cannot even
// `cd` into, surfacing as an agent that fails on its own first command.
//
// No ACL here, unlike `home` above. `home` needs one because the file browser reads a member's home
// FROM INSIDE the platform process, as the service user. Command HQ has no equivalent: it reads a
// member's commander transcripts the same way `/chat` already reads any member's transcripts — through
// `listTranscriptsAs`, which runs the read AS the member, never as the service user directly. So plain
// ownership is the whole requirement, the same as `~/.claude/projects` itself.
const commandersDir = getCommandersDir(params.email);
if (!existsSync(commandersDir)) await mkdir(commandersDir, { recursive: true });
const giveCommanders = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, commandersDir]);
if (!giveCommanders.ok) return { ok: false, error: `chown of ${commandersDir} failed: ${giveCommanders.out}` };
const closeCommanders = await run(['sudo', '-n', 'chmod', '700', commandersDir]);
if (!closeCommanders.ok) return { ok: false, error: `chmod of ${commandersDir} failed: ${closeCommanders.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