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