Files
jason.woltje 87db40e0cd fix(cli,seat,discord,organization,control-board,harness,plugins,webui): review round: launcher reasons, session, discord reads, mosaic-config and keys by label (#1873)
Review "changes 8" fix round for org-redact6.

- launcher: a harness module's throw names its system code or class,
  never its message; preflight, loader, bundle, checkout, sandbox and
  route text is relayed with host paths replaced (relay()).
- claude-code and pi preflights name MOSAIC_CLAUDE_BIN and the
  repository's node_modules, never a path; persona and skill bundle
  errors and sandboxProblem name their file or the sandbox by label.
- seat session: thread store, legacy store, overlap, busy lock and
  newRun refusals name the place under the data root.
- discord: unknown keys, binding names and turn ids are described when
  not plain; approvals, journal and repository read errors name the file
  and code; git drops stderr lines that carry a host path and a spawn
  failure names its code only.
- seat, control-board: registration fields and seen keys are described
  when not plain; invalid seat name and layout state the constraint.
- organization: the registry names the config directory and registry
  file by label; --project is quoted only when it is a plain name;
  systemConfigProblem moves to its own module.
- scripts/mosaic-config.mjs: the config by where it came from, the data
  root and its marker by role, keys and arguments only when plain, and
  error codes in place of raw messages. Exit codes unchanged.
- webui sso-config: an unknown console key is named only when plain.

Sentinel tests in each package's path-redaction-labels file fail on
BASE; pins that quoted a path now assert the label and its absence.
2026-11-11 08:53:31 -06:00

277 lines
14 KiB
JavaScript

// Seat registration: one small record per seat under <dataRoot>/seats/<layout>/<seat>/
// registration.json, written by `mosaic launch <seat>` and read by the control
// board so the board can show what a seat was told to do instead of guessing.
//
// A registration is rewritten on every launch. It is a launch record, not a
// board file: the board must not write here, and a scan never changes it.
// `mosaic seat task <seat> <text>` changes the task field only.
//
// Records are keyed by layout and seat name, <seats>/<layout>/<seat>/, because
// a name alone is not unique (this repository and the fleet both have a
// "darkwing"). Two layouts of seat directory are known:
// repo <repo>/agents/<seat>/launch.sh, where <repo>/.git exists.
// Sessions live in <repo>/.pi/state/<seat>/sessions.
// fleet <seatDir>/launch.sh with <seatDir>/.pi (the ~/.mosaic fleet layout).
// Sessions live in <seatDir>/.pi/agent/sessions.
// Anything else is layout "unknown" and records nulls; nothing is guessed.
import { parseJsonFileText } from "../../organization/src/util.mjs";
import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, statSync, realpathSync } from "node:fs";
import { join, basename, dirname, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os";
import { spawnSync } from "node:child_process";
export const REGISTRATION_VERSION = 1;
export const SEAT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
export const TASK_LIMIT = 2000;
// Who set the task (#1511): caller-supplied attribution, bounded to one
// short token so it cannot carry markup or whitespace. The bound is a syntax
// limit, not a privacy filter: a numeric ID or an email-like name still fits,
// so callers choose what they put here. It is what the caller claimed, not
// an authenticated identity, and it grants nothing: the board displays it
// and does no more with it.
export const SET_BY_NAME = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,63}$/;
export const SET_BY_UNKNOWN = "unknown";
export const SET_BY_ENV = "MOSAIC_AGENT_NAME";
// exitCode follows docs/TOOLS.md: 1 operation failed, 2 invalid data or
// configuration, 4 usage.
export class SeatError extends Error {
constructor(message, exitCode = 2) {
super(message);
this.name = "SeatError";
this.exitCode = exitCode;
}
}
export function defaultConfigPath(env = process.env) {
return env.MOSAIC_CONFIG ? resolve(env.MOSAIC_CONFIG) : join(homedir(), ".config", "mosaic-dev", "config.json");
}
// Fail closed: the config must exist, parse, and name an absolute dataRoot.
// Only dataRoot is read here; scripts/mosaic-config.mjs owns full validation.
// Refusals name the config, never its path (#1873).
export function loadDataRoot(path = defaultConfigPath()) {
if (!existsSync(path)) throw new SeatError("config not found");
let raw;
try {
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new SeatError(`config is not valid JSON${error.code ? ` (${error.code})` : ""}`);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new SeatError("config is not an object");
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new SeatError("config.dataRoot must be an absolute path");
return raw.dataRoot;
}
export function seatsDir(dataRoot) {
return join(dataRoot, "seats");
}
export const LAYOUTS = Object.freeze(["repo", "fleet", "unknown"]);
// A refusal quotes a key or name only when it is a plain name; anything that
// could carry a host path or a secret is described, never echoed (#1873).
export const plainKey = (k) => (/^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(String(k)) ? JSON.stringify(k) : "that is not a plain name");
// Keyed by layout and seat, because a seat name is not unique across
// layouts (this repository and the fleet both have a "darkwing").
export function registrationPath(seats, seat, layout) {
if (!SEAT_NAME.test(String(seat))) throw new SeatError("invalid seat name (lowercase letters, digits, ., _ or -, at most 64)", 4);
if (!LAYOUTS.includes(layout)) throw new SeatError(`invalid layout (one of ${LAYOUTS.join(", ")})`, 4);
return join(seats, layout, seat, "registration.json");
}
// Turn "<name>" or "<path to seat dir>" into everything a launch needs to
// know. A name resolves under <repo>/agents; a path is taken as the seat dir.
export function resolveSeat(arg, { repo = process.cwd() } = {}) {
if (typeof arg !== "string" || arg.length === 0) throw new SeatError("seat name or seat directory required", 4);
let seatDir;
if (arg.includes("/") || (existsSync(arg) && statSync(arg).isDirectory())) seatDir = resolve(arg);
else if (SEAT_NAME.test(arg)) seatDir = join(resolve(repo), "agents", arg);
else throw new SeatError(`invalid seat name: ${JSON.stringify(arg)}`, 4);
const seat = basename(seatDir);
if (!SEAT_NAME.test(seat)) throw new SeatError(`invalid seat name: ${JSON.stringify(seat)}`, 4);
// Named by the checked seat name, never the directory's path (#1873).
if (!existsSync(seatDir) || !statSync(seatDir).isDirectory()) throw new SeatError(`no such seat directory: seat ${seat}`);
const launchScript = join(seatDir, "launch.sh");
if (!existsSync(launchScript) || !statSync(launchScript).isFile()) throw new SeatError(`seat has no launch.sh: seat ${seat}`);
if ((statSync(launchScript).mode & 0o111) === 0) throw new SeatError(`launch script is not executable: launch.sh of seat ${seat}`);
const parent = dirname(seatDir);
const root = basename(parent) === "agents" ? dirname(parent) : null;
if (root && existsSync(join(root, ".git"))) {
return {
seat, seatDir, launchScript, layout: "repo",
project: basename(root),
sessionsDir: join(root, ".pi", "state", seat, "sessions"),
defaultWorkspace: root,
};
}
if (existsSync(join(seatDir, ".pi"))) {
return { seat, seatDir, launchScript, layout: "fleet", project: null, sessionsDir: join(seatDir, ".pi", "agent", "sessions"), defaultWorkspace: null };
}
return { seat, seatDir, launchScript, layout: "unknown", project: null, sessionsDir: null, defaultWorkspace: null };
}
// The tmux session this process runs in, from the TMUX/TMUX_PANE variables
// tmux sets for its panes. null outside tmux or when tmux cannot answer.
// socket is null on the default server, else the socket file's name
// (the value tmux -L takes), matching the control board's spec shape.
export function tmuxContext({ env = process.env, exec = spawnSync } = {}) {
const tmux = env.TMUX;
if (typeof tmux !== "string" || tmux.length === 0) return null;
const socketPath = tmux.split(",")[0];
if (!socketPath) return null;
const args = ["-S", socketPath, "display-message", "-p"];
if (env.TMUX_PANE) args.push("-t", env.TMUX_PANE);
args.push("#{session_name}");
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
if (r.error || r.status !== 0) return null;
const session = String(r.stdout ?? "").trim();
if (!session) return null;
const socket = basename(socketPath);
return { socket: socket === "default" ? null : socket, session };
}
const FIELDS = Object.freeze([
"version", "seat", "project", "task", "workspace", "tmux", "harness",
"startedAt", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "updatedAt",
"taskSetBy",
]);
const isNullableString = (v) => v === null || typeof v === "string";
const isTimestamp = (v) => typeof v === "string" && Number.isFinite(Date.parse(v));
// Shape check for a record read from disk or about to be written. Throws
// SeatError with the failing field; never echoes the offending value.
export function validateRegistration(record) {
if (!record || typeof record !== "object" || Array.isArray(record)) throw new SeatError("registration is not an object");
for (const key of Object.keys(record)) if (!FIELDS.includes(key)) throw new SeatError(`registration has an unknown field: ${plainKey(key)}`);
if (record.version !== REGISTRATION_VERSION) throw new SeatError("registration has an unsupported version");
if (typeof record.seat !== "string" || !SEAT_NAME.test(record.seat)) throw new SeatError("registration.seat is invalid");
if (typeof record.task !== "string" || record.task.length > TASK_LIMIT) throw new SeatError("registration.task must be a string");
// Optional: records written before #1511 have no taskSetBy and still load
// (the board shows "unknown"); no version change, no migration on read.
if (record.taskSetBy !== undefined && !(typeof record.taskSetBy === "string" && SET_BY_NAME.test(record.taskSetBy))) throw new SeatError("registration.taskSetBy is invalid");
for (const key of ["project", "workspace", "harness", "sessionsDir", "seatDir", "launchScript"]) {
if (!isNullableString(record[key])) throw new SeatError(`registration.${key} must be a string or null`);
}
if (!LAYOUTS.includes(record.layout)) throw new SeatError("registration.layout is invalid");
if (record.tmux !== null) {
const t = record.tmux;
if (!t || typeof t !== "object" || Array.isArray(t)) throw new SeatError("registration.tmux must be an object or null");
if (!isNullableString(t.socket) || typeof t.session !== "string") throw new SeatError("registration.tmux is invalid");
}
if (!isTimestamp(record.startedAt)) throw new SeatError("registration.startedAt must be a timestamp");
if (record.updatedAt !== null && !isTimestamp(record.updatedAt)) throw new SeatError("registration.updatedAt must be a timestamp or null");
if (record.pid !== null && !(Number.isInteger(record.pid) && record.pid > 0)) throw new SeatError("registration.pid must be a positive integer or null");
return record;
}
export function makeRegistration({ resolved, task = "", project, workspace, harness = null, tmux = null, pid = null, now = () => new Date() }) {
if (typeof task !== "string") throw new SeatError("task must be a string", 4);
if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4);
return validateRegistration({
version: REGISTRATION_VERSION,
seat: resolved.seat,
project: project ?? resolved.project ?? null,
task,
workspace: workspace ?? resolved.defaultWorkspace ?? null,
tmux,
harness,
startedAt: now().toISOString(),
pid,
sessionsDir: resolved.sessionsDir,
seatDir: resolved.seatDir,
launchScript: resolved.launchScript,
layout: resolved.layout,
updatedAt: null,
});
}
// Private: directory 0700, file 0600, atomic tmp+rename so a reader never
// sees a half-written record.
export function writeRegistration(seats, record) {
validateRegistration(record);
const path = registrationPath(seats, record.seat, record.layout);
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const tmp = `${path}.tmp-${process.pid}`;
writeFileSync(tmp, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
renameSync(tmp, path);
return path;
}
// null when the seat has no registration. A present but unreadable or
// malformed record throws SeatError rather than being treated as absent.
export function readRegistration(seats, seat, layout) {
const path = registrationPath(seats, seat, layout);
if (!existsSync(path)) return null;
let parsed;
try {
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new SeatError(`registration ${layout}/${seat} is not valid JSON${error.code ? ` (${error.code})` : ""}`);
}
try {
const record = validateRegistration(parsed);
if (record.seat !== seat || record.layout !== layout) throw new SeatError("registration does not match its path");
return record;
} catch (err) {
throw new SeatError(`${err.message}: registration ${layout}/${seat}`);
}
}
// Every registration for a seat name, across layouts. Unreadable records
// throw; a name with no record gives [].
export function findRegistrations(seats, seat) {
return LAYOUTS.map((layout) => readRegistration(seats, seat, layout)).filter(Boolean);
}
// Who a `seat task` is attributed to: an explicit `--by NAME`, else the
// MOSAIC_AGENT_NAME variable, else "unknown". An explicit value must match
// SET_BY_NAME; so must a non-empty environment value, because a malformed
// or unexpected value there is not the same as no value and is refused
// rather than silently reported as "unknown". Neither value is echoed back.
// The result is a claim by the caller, nothing more.
export function resolveSetBy({ by = null, env = process.env } = {}) {
if (by !== null && by !== undefined) {
if (typeof by !== "string" || !SET_BY_NAME.test(by)) throw new SeatError(`--by must be 1-64 characters of letters, digits, . _ @ : or -, starting with a letter or digit`, 4);
return by;
}
const fromEnv = env[SET_BY_ENV];
if (fromEnv === undefined || fromEnv === "") return SET_BY_UNKNOWN;
if (typeof fromEnv !== "string" || !SET_BY_NAME.test(fromEnv)) throw new SeatError(`${SET_BY_ENV} is set but is not a valid name (1-64 characters of letters, digits, . _ @ : or -); pass --by NAME or unset it`, 4);
return fromEnv;
}
// Change the task field, and record who set it (taskSetBy), and updatedAt.
// Every other field, startedAt included, is carried over unchanged. Refuses
// when the seat was never launched through `mosaic launch`, because there is
// nothing to attach the task to. A name that exists in more than one layout
// must be qualified with layout.
export function updateTask(seats, seat, task, { layout = null, now = () => new Date(), setBy = SET_BY_UNKNOWN } = {}) {
if (typeof task !== "string") throw new SeatError("task must be a string", 4);
if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4);
if (typeof setBy !== "string" || !SET_BY_NAME.test(setBy)) throw new SeatError("setBy must be a valid name", 4);
const found = layout ? [readRegistration(seats, seat, layout)].filter(Boolean) : findRegistrations(seats, seat);
if (found.length === 0) throw new SeatError(`no registration for seat ${seat}; launch it through mosaic launch first`, 1);
if (found.length > 1) throw new SeatError(`seat ${seat} is registered in more than one layout (${found.map((r) => r.layout).join(", ")}); pass --layout`, 4);
const record = found[0];
const updated = { ...record, task, taskSetBy: setBy, updatedAt: now().toISOString() };
writeRegistration(seats, updated);
return updated;
}
// True when two paths name the same directory: equal once resolved, or the
// same real path when both exist (symlinked checkouts).
export function samePath(a, b) {
if (typeof a !== "string" || typeof b !== "string") return false;
if (resolve(a) === resolve(b)) return true;
try {
return realpathSync(a) === realpathSync(b);
} catch {
return false;
}
}