fix(fleet): resolve-then-validate symlink guard + framework helper resolution (#1380) (#1383)
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-be-01 <[email protected]>
This commit was merged in pull request #1383.
This commit is contained in:
@@ -9,8 +9,9 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { readRegularFileSecure } from './secure-file.js';
|
||||
import { resolveBrainHome } from './brain-home.js';
|
||||
import {
|
||||
parseFleetRosterV1,
|
||||
resolveInstalledFleetRosterPath,
|
||||
@@ -260,7 +261,13 @@ context and have an authorized operator relaunch only this exact roster member w
|
||||
|
||||
function validateAgentSendHelper(path: string, mosaicHome: string): string | undefined {
|
||||
try {
|
||||
readRegularFileSecure(path, { root: mosaicHome, executable: true });
|
||||
readRegularFileSecure(path, {
|
||||
root: mosaicHome,
|
||||
executable: true,
|
||||
// The helper tree may live under the framework config home while this
|
||||
// caller's mosaicHome is the brain; both are framework-owned roots.
|
||||
symlinkTargetRoots: [resolveBrainHome(mosaicHome)],
|
||||
});
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
@@ -268,8 +275,48 @@ function validateAgentSendHelper(path: string, mosaicHome: string): string | und
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework install homes probed for tools/tmux/agent-send.sh (stack#1380 M2).
|
||||
* The helper ships with the FRAMEWORK install, which on split-home layouts is
|
||||
* the config home — not the brain (~/.mosaic carries fleet state, no tools).
|
||||
*/
|
||||
function frameworkHelperHomes(mosaicHome: string): string[] {
|
||||
const homes = [resolve(mosaicHome)];
|
||||
const envHome = process.env['MOSAIC_HOME'];
|
||||
if (envHome && envHome.trim() !== '' && resolve(envHome) !== resolve(mosaicHome)) {
|
||||
homes.push(resolve(envHome));
|
||||
}
|
||||
const configDefault = join(homedir(), '.config', 'mosaic');
|
||||
if (resolve(configDefault) !== resolve(mosaicHome)) homes.push(configDefault);
|
||||
return homes;
|
||||
}
|
||||
|
||||
function resolveAgentSendHelper(mosaicHome: string): { path: string; error?: string } {
|
||||
const homes = frameworkHelperHomes(mosaicHome);
|
||||
for (const home of homes) {
|
||||
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
|
||||
if (!existsSync(helper)) continue;
|
||||
const error = validateAgentSendHelper(helper, home);
|
||||
if (!error) return { path: helper };
|
||||
// Present but unsafe: surface that verdict instead of silently probing on.
|
||||
return { path: helper, error };
|
||||
}
|
||||
return {
|
||||
path: join(resolve(mosaicHome), 'tools', 'tmux', 'agent-send.sh'),
|
||||
error:
|
||||
`fleet helper agent-send.sh was not found under any framework install home ` +
|
||||
`(${homes.map((h) => join(h, 'tools', 'tmux', 'agent-send.sh')).join('; ')}). ` +
|
||||
`Verify the framework install for this host (the helper ships with the framework ` +
|
||||
`config home; the brain home carries fleet state, not tools) and have an authorized ` +
|
||||
`operator restore it if missing.`,
|
||||
};
|
||||
}
|
||||
|
||||
function helperFailureGuidance(reason: string): string {
|
||||
return `${reason}. Run \`mosaic update --repair-tools\` to restore the supported current-version helper and TOOLS contract, then retry exact-member composition; no active context or session was rewritten.`;
|
||||
// stack#1380 M5a: `mosaic update --repair-tools` is a forbidden remedy on the
|
||||
// affected estate (and wrong for a layout/missing-helper failure). Name the
|
||||
// actual recovery shape instead.
|
||||
return `${reason}. Verify the framework install provides tools/tmux/agent-send.sh under the framework config home and that the roster resolves (split-home layouts symlink the roster into the brain); contact the operator if it persists; no active context or session was rewritten.`;
|
||||
}
|
||||
|
||||
export function resolveFleetIdentity(
|
||||
@@ -278,9 +325,15 @@ export function resolveFleetIdentity(
|
||||
localHost: string = shortHostname(),
|
||||
): FleetIdentityResult {
|
||||
if (!requestedName) return { ok: true };
|
||||
const agentSendPath = join(mosaicHome, 'tools', 'tmux', 'agent-send.sh');
|
||||
const helperError = validateAgentSendHelper(agentSendPath, mosaicHome);
|
||||
if (helperError) return { ok: false, error: helperFailureGuidance(helperError) };
|
||||
const helper = resolveAgentSendHelper(mosaicHome);
|
||||
if (helper.error) return { ok: false, error: helperFailureGuidance(helper.error) };
|
||||
const agentSendPath = helper.path;
|
||||
|
||||
// Split-home layouts unify the roster by symlinking
|
||||
// <configHome>/fleet/roster.yaml -> <brain>/fleet/roster.yaml. The secure
|
||||
// read resolves that framework-created symlink when the brain is a
|
||||
// sanctioned target root (stack#1380 M1).
|
||||
const rosterSymlinkRoots = [resolveBrainHome(mosaicHome)];
|
||||
|
||||
let rosterPath: string;
|
||||
try {
|
||||
@@ -301,7 +354,10 @@ export function resolveFleetIdentity(
|
||||
let roster: FleetRoster;
|
||||
try {
|
||||
roster = parseFleetRosterV1(
|
||||
readRegularFileSecure(rosterPath, { root: mosaicHome }).content.toString('utf8'),
|
||||
readRegularFileSecure(rosterPath, {
|
||||
root: mosaicHome,
|
||||
symlinkTargetRoots: rosterSymlinkRoots,
|
||||
}).content.toString('utf8'),
|
||||
rosterPath.endsWith('.json') ? 'json' : 'yaml',
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -399,7 +455,9 @@ function boundedContractDigest(
|
||||
}
|
||||
|
||||
function replacementGuidance(): string {
|
||||
return `Run \`mosaic update --repair-tools\` to make a digest-qualified backup and restore the supported current-version TOOLS contract, then have an authorized operator explicitly relaunch the exact roster member. The active context was not rewritten.`;
|
||||
// stack#1380 M5a: never recommend the forbidden --repair-tools remedy from
|
||||
// error text; name the operator-verified recovery shape instead.
|
||||
return `Verify the installed TOOLS contract against the framework source with an authorized operator (the installed file must byte-match the supported current version) and have the operator explicitly relaunch the exact roster member. The active context was not rewritten.`;
|
||||
}
|
||||
|
||||
/** Detect preserved installed TOOLS.md drift without changing it. */
|
||||
|
||||
Reference in New Issue
Block a user