fix(fleet): enforce exact comms authority (#787)
This commit was merged in pull request #787.
This commit is contained in:
@@ -15,16 +15,34 @@
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
readdirSync,
|
||||
closeSync,
|
||||
constants,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
fchmodSync,
|
||||
fsyncSync,
|
||||
linkSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseFleetRosterV1, resolveInstalledFleetRosterPath } from '../fleet/fleet-roster-v1.js';
|
||||
import {
|
||||
assertCanonicalContainment,
|
||||
assertNoSymlinkAncestors,
|
||||
ensureManagedDirectory,
|
||||
readRegularFileSecure,
|
||||
} from '../fleet/secure-file.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +72,10 @@ const CACHE_FILE = join(CACHE_DIR, 'update-check.json');
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const NETWORK_TIMEOUT_MS = 5_000;
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function npmExec(args: string, timeoutMs = NETWORK_TIMEOUT_MS): string {
|
||||
@@ -500,6 +522,346 @@ export function buildReseedCommand(
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolsRepairResult {
|
||||
ok: boolean;
|
||||
changed: boolean;
|
||||
backupPath?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ToolsRepairHooks {
|
||||
beforeCommit?: (which: 'backup' | 'tools' | 'helper') => void;
|
||||
}
|
||||
|
||||
function optionalSecureFile(
|
||||
path: string,
|
||||
root: string,
|
||||
): ReturnType<typeof readRegularFileSecure> | undefined {
|
||||
try {
|
||||
return readRegularFileSecure(path, { root });
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function stageManagedFile(
|
||||
root: string,
|
||||
directory: string,
|
||||
target: string,
|
||||
content: Buffer,
|
||||
mode: number,
|
||||
): string {
|
||||
assertCanonicalContainment(root, target);
|
||||
ensureManagedDirectory(root, directory);
|
||||
assertNoSymlinkAncestors(target);
|
||||
const staged = join(directory, `.${basename(target)}.repair-${process.pid}-${cryptoRandom()}`);
|
||||
assertCanonicalContainment(root, staged);
|
||||
const fd = openSync(
|
||||
staged,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
writeFileSync(fd, content);
|
||||
fchmodSync(fd, mode);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
function cryptoRandom(): string {
|
||||
return randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
interface ManagedOriginal {
|
||||
path: string;
|
||||
snapshot?: ReturnType<typeof readRegularFileSecure>;
|
||||
}
|
||||
|
||||
function assertManagedOriginalUnchanged(original: ManagedOriginal, root: string): void {
|
||||
if (!original.snapshot) {
|
||||
try {
|
||||
lstatSync(original.path);
|
||||
throw new Error(`repair destination appeared during staging: ${original.path}`);
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const current = readRegularFileSecure(original.path, { root });
|
||||
if (
|
||||
current.dev !== original.snapshot.dev ||
|
||||
current.ino !== original.snapshot.ino ||
|
||||
current.mode !== original.snapshot.mode ||
|
||||
!current.content.equals(original.snapshot.content)
|
||||
) {
|
||||
throw new Error(`repair destination changed during staging: ${original.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function installBackupNoClobber(staged: string, target: string, root: string): void {
|
||||
assertCanonicalContainment(root, target);
|
||||
assertNoSymlinkAncestors(target);
|
||||
try {
|
||||
lstatSync(target);
|
||||
throw new Error(`digest-qualified backup collision at ${target}`);
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
linkSync(staged, target);
|
||||
unlinkSync(staged);
|
||||
}
|
||||
|
||||
function atomicInstall(staged: string, target: string, root: string): void {
|
||||
assertCanonicalContainment(root, target);
|
||||
assertNoSymlinkAncestors(target);
|
||||
try {
|
||||
const current = lstatSync(target);
|
||||
if (current.isSymbolicLink() || !current.isFile()) {
|
||||
throw new Error(`repair destination is not a regular file: ${target}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT')) throw error;
|
||||
}
|
||||
renameSync(staged, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly repair the user-owned TOOLS contract and required helper from the
|
||||
* bundled current framework. Existing divergent TOOLS content is preserved in
|
||||
* a digest-qualified no-clobber backup; repeated repairs are idempotent.
|
||||
*/
|
||||
export function repairFleetCommsTools(
|
||||
frameworkRoot = resolveBundledFrameworkRoot(),
|
||||
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||
hooks: ToolsRepairHooks = {},
|
||||
): ToolsRepairResult {
|
||||
const sourceTools = join(frameworkRoot, 'defaults', 'TOOLS.md');
|
||||
const sourceHelper = join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh');
|
||||
const installedTools = join(mosaicHome, 'TOOLS.md');
|
||||
const helperDirectory = join(mosaicHome, 'tools', 'tmux');
|
||||
const installedHelper = join(helperDirectory, 'agent-send.sh');
|
||||
let stagedBackup: string | undefined;
|
||||
let stagedTools: string | undefined;
|
||||
let stagedHelper: string | undefined;
|
||||
let rollbackTools: string | undefined;
|
||||
let rollbackHelper: string | undefined;
|
||||
let committedBackup = false;
|
||||
let committedTools = false;
|
||||
let committedHelper = false;
|
||||
let createdHome = false;
|
||||
let createdToolsDirectory = false;
|
||||
let createdHelperDirectory = false;
|
||||
let backupPath: string | undefined;
|
||||
let toolsOriginal: ManagedOriginal | undefined;
|
||||
let helperOriginal: ManagedOriginal | undefined;
|
||||
try {
|
||||
const sourceToolsSnapshot = readRegularFileSecure(sourceTools, { root: frameworkRoot });
|
||||
const sourceHelperSnapshot = readRegularFileSecure(sourceHelper, {
|
||||
root: frameworkRoot,
|
||||
executable: true,
|
||||
});
|
||||
if (!sourceToolsSnapshot.content.includes('<!-- fleet-comms-contract: 1 -->')) {
|
||||
return { ok: false, changed: false, reason: 'bundled TOOLS contract has wrong version' };
|
||||
}
|
||||
|
||||
assertCanonicalContainment(mosaicHome, installedTools);
|
||||
assertCanonicalContainment(mosaicHome, installedHelper);
|
||||
assertNoSymlinkAncestors(mosaicHome);
|
||||
const homeExisted = existsSync(mosaicHome);
|
||||
const toolsDirectory = dirname(helperDirectory);
|
||||
const toolsDirectoryExisted = existsSync(toolsDirectory);
|
||||
const helperDirectoryExisted = existsSync(helperDirectory);
|
||||
if (homeExisted) {
|
||||
const homeStat = lstatSync(mosaicHome);
|
||||
if (homeStat.isSymbolicLink()) {
|
||||
throw new Error(`managed root is a symbolic link: ${mosaicHome}`);
|
||||
}
|
||||
if (!homeStat.isDirectory()) {
|
||||
throw new Error(`managed root is not a real directory: ${mosaicHome}`);
|
||||
}
|
||||
}
|
||||
|
||||
const installedToolsSnapshot = homeExisted
|
||||
? optionalSecureFile(installedTools, mosaicHome)
|
||||
: undefined;
|
||||
let installedHelperSnapshot: ReturnType<typeof readRegularFileSecure> | undefined;
|
||||
let installedHelperExecutable = false;
|
||||
if (homeExisted) {
|
||||
try {
|
||||
installedHelperSnapshot = readRegularFileSecure(installedHelper, {
|
||||
root: mosaicHome,
|
||||
executable: true,
|
||||
});
|
||||
installedHelperExecutable = true;
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, 'ENOENT') && !isNodeErrorCode(error, 'EACCES')) throw error;
|
||||
if (isNodeErrorCode(error, 'EACCES')) {
|
||||
installedHelperSnapshot = optionalSecureFile(installedHelper, mosaicHome);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolsChanged = !installedToolsSnapshot?.content.equals(sourceToolsSnapshot.content);
|
||||
const helperChanged =
|
||||
!installedHelperExecutable ||
|
||||
!installedHelperSnapshot?.content.equals(sourceHelperSnapshot.content);
|
||||
if (!toolsChanged && !helperChanged) return { ok: true, changed: false };
|
||||
|
||||
toolsOriginal = { path: installedTools, snapshot: installedToolsSnapshot };
|
||||
helperOriginal = { path: installedHelper, snapshot: installedHelperSnapshot };
|
||||
|
||||
ensureManagedDirectory(dirname(mosaicHome), mosaicHome);
|
||||
createdHome = !homeExisted;
|
||||
ensureManagedDirectory(mosaicHome, helperDirectory);
|
||||
createdToolsDirectory = !toolsDirectoryExisted;
|
||||
createdHelperDirectory = !helperDirectoryExisted;
|
||||
|
||||
if (toolsChanged) {
|
||||
stagedTools = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
installedTools,
|
||||
sourceToolsSnapshot.content,
|
||||
sourceToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
if (installedToolsSnapshot) {
|
||||
const digest = createHash('sha256')
|
||||
.update(installedToolsSnapshot.content)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
backupPath = `${installedTools}.pre-fleet-comms-${digest}.bak`;
|
||||
const existingBackup = optionalSecureFile(backupPath, mosaicHome);
|
||||
if (existingBackup && !existingBackup.content.equals(installedToolsSnapshot.content)) {
|
||||
throw new Error(`digest-qualified backup collision at ${backupPath}`);
|
||||
}
|
||||
if (!existingBackup) {
|
||||
stagedBackup = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
backupPath,
|
||||
installedToolsSnapshot.content,
|
||||
installedToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
rollbackTools = stageManagedFile(
|
||||
mosaicHome,
|
||||
mosaicHome,
|
||||
installedTools,
|
||||
installedToolsSnapshot.content,
|
||||
installedToolsSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (helperChanged) {
|
||||
stagedHelper = stageManagedFile(
|
||||
mosaicHome,
|
||||
helperDirectory,
|
||||
installedHelper,
|
||||
sourceHelperSnapshot.content,
|
||||
sourceHelperSnapshot.mode & 0o777,
|
||||
);
|
||||
if (installedHelperSnapshot) {
|
||||
rollbackHelper = stageManagedFile(
|
||||
mosaicHome,
|
||||
helperDirectory,
|
||||
installedHelper,
|
||||
installedHelperSnapshot.content,
|
||||
installedHelperSnapshot.mode & 0o777,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
if (stagedBackup && backupPath) {
|
||||
hooks.beforeCommit?.('backup');
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
installBackupNoClobber(stagedBackup, backupPath, mosaicHome);
|
||||
stagedBackup = undefined;
|
||||
committedBackup = true;
|
||||
}
|
||||
if (stagedTools) {
|
||||
hooks.beforeCommit?.('tools');
|
||||
assertManagedOriginalUnchanged(toolsOriginal, mosaicHome);
|
||||
atomicInstall(stagedTools, installedTools, mosaicHome);
|
||||
stagedTools = undefined;
|
||||
committedTools = true;
|
||||
}
|
||||
if (stagedHelper) {
|
||||
hooks.beforeCommit?.('helper');
|
||||
assertManagedOriginalUnchanged(helperOriginal, mosaicHome);
|
||||
atomicInstall(stagedHelper, installedHelper, mosaicHome);
|
||||
stagedHelper = undefined;
|
||||
committedHelper = true;
|
||||
}
|
||||
if (rollbackTools) unlinkSync(rollbackTools);
|
||||
if (rollbackHelper) unlinkSync(rollbackHelper);
|
||||
return { ok: true, changed: true, backupPath };
|
||||
} catch (error) {
|
||||
const failures: string[] = [];
|
||||
try {
|
||||
if (committedHelper) {
|
||||
if (rollbackHelper) atomicInstall(rollbackHelper, installedHelper, mosaicHome);
|
||||
else unlinkSync(installedHelper);
|
||||
rollbackHelper = undefined;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`helper rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
try {
|
||||
if (committedTools) {
|
||||
if (rollbackTools) atomicInstall(rollbackTools, installedTools, mosaicHome);
|
||||
else unlinkSync(installedTools);
|
||||
rollbackTools = undefined;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`TOOLS rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
try {
|
||||
if (committedBackup && backupPath) {
|
||||
unlinkSync(backupPath);
|
||||
committedBackup = false;
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
failures.push(`backup rollback failed: ${String(rollbackError)}`);
|
||||
}
|
||||
for (const staged of [stagedBackup, stagedTools, stagedHelper, rollbackTools, rollbackHelper]) {
|
||||
if (!staged) continue;
|
||||
try {
|
||||
unlinkSync(staged);
|
||||
} catch {
|
||||
failures.push(`staging cleanup failed: ${staged}`);
|
||||
}
|
||||
}
|
||||
for (const [created, directory] of [
|
||||
[createdHelperDirectory, helperDirectory],
|
||||
[createdToolsDirectory, dirname(helperDirectory)],
|
||||
[createdHome, mosaicHome],
|
||||
] as const) {
|
||||
if (!created) continue;
|
||||
try {
|
||||
rmdirSync(directory);
|
||||
} catch (cleanupError) {
|
||||
if (!isNodeErrorCode(cleanupError, 'ENOENT')) {
|
||||
failures.push(`directory cleanup failed: ${directory}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
changed: failures.length > 0,
|
||||
backupPath: committedBackup ? backupPath : undefined,
|
||||
reason: failures.length > 0 ? `${reason}; ${failures.join('; ')}` : reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed the framework from the freshly-installed package. Returns a result
|
||||
* describing what happened (so callers can message + decide on relaunch).
|
||||
@@ -591,25 +953,20 @@ export function checkFrameworkDrift(
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort parse of the fleet roster for agent names (used to relaunch
|
||||
* durable agents after a re-seed). Returns [] when no roster exists.
|
||||
* Canonically parse the installed fleet roster for relaunch targets. JSON is
|
||||
* considered only when roster.yaml is genuinely absent; all other failures
|
||||
* return no targets rather than guessing.
|
||||
*/
|
||||
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (!existsSync(rosterPath)) return [];
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(rosterPath, 'utf-8');
|
||||
const rosterPath = resolveInstalledFleetRosterPath(mosaicHome);
|
||||
const source = readFileSync(rosterPath, 'utf8');
|
||||
return parseFleetRosterV1(source, rosterPath.endsWith('.json') ? 'json' : 'yaml').agents.map(
|
||||
(agent) => agent.name,
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// Roster agents are listed as `- name: <id>` entries under `agents:`.
|
||||
const names: string[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
||||
if (m && m[1]) names.push(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user