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.