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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user