fix(fleet): enforce exact comms authority (#787)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

This commit was merged in pull request #787.
This commit is contained in:
2026-07-16 00:32:23 +00:00
parent c1aecfabe9
commit adad486b6f
22 changed files with 3534 additions and 859 deletions

View File

@@ -1,5 +1,18 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import {
chmodSync,
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
@@ -11,8 +24,8 @@ import {
readInstalledFrameworkVersion,
readBundledFrameworkVersion,
checkFrameworkDrift,
repairFleetCommsTools,
} from './update-checker.js';
import { existsSync, readFileSync } from 'node:fs';
/**
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
@@ -66,6 +79,7 @@ describe('readRosterAgentNames', () => {
join(home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: orchestrator',
' runtime: pi',
@@ -77,6 +91,212 @@ describe('readRosterAgentNames', () => {
);
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
});
it('extracts agent names from a JSON-only roster', () => {
mkdirSync(join(home, 'fleet'), { recursive: true });
writeFileSync(
join(home, 'fleet', 'roster.json'),
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [
{ name: 'orchestrator', runtime: 'pi', class: 'orchestrator' },
{ name: 'coder0', runtime: 'claude', class: 'worker' },
],
}),
);
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0']);
});
});
describe('repairFleetCommsTools', () => {
let root: string;
let framework: string;
let home: string;
const toolsContent = '# tools\n<!-- fleet-comms-contract: 1 -->\n';
const helperContent = '#!/bin/sh\nexit 0\n';
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-tools-repair-'));
framework = join(root, 'framework');
home = join(root, 'home');
mkdirSync(join(framework, 'defaults'), { recursive: true });
mkdirSync(join(framework, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(framework, 'defaults', 'TOOLS.md'), toolsContent);
const helper = join(framework, 'tools', 'tmux', 'agent-send.sh');
writeFileSync(helper, helperContent);
chmodSync(helper, 0o755);
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
it('restores a partially deleted current-version installation without package updates', () => {
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
const result = repairFleetCommsTools(framework, home);
expect(result).toMatchObject({ ok: true, changed: true });
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(toolsContent);
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(helperContent);
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o111).not.toBe(0);
});
it('creates a digest-qualified no-clobber backup and is idempotent', () => {
mkdirSync(home, { recursive: true });
const stale = '# user tools\n';
writeFileSync(join(home, 'TOOLS.md'), stale);
const first = repairFleetCommsTools(framework, home);
expect(first).toMatchObject({ ok: true, changed: true });
expect(first.backupPath).toMatch(/\.pre-fleet-comms-[a-f0-9]{16}\.bak$/);
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
const second = repairFleetCommsTools(framework, home);
expect(second).toEqual({ ok: true, changed: false, backupPath: undefined });
expect(readFileSync(first.backupPath!, 'utf8')).toBe(stale);
});
it('rejects an installed helper symlink without modifying its target', () => {
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(home, 'TOOLS.md'), toolsContent);
const target = join(root, 'external-helper');
writeFileSync(target, 'do not touch\n');
symlinkSync(target, join(home, 'tools', 'tmux', 'agent-send.sh'));
const result = repairFleetCommsTools(framework, home);
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.reason).toContain('symbolic link');
expect(readFileSync(target, 'utf8')).toBe('do not touch\n');
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true);
});
it('refuses a helper directory before replacing stale TOOLS content', () => {
mkdirSync(join(home, 'tools', 'tmux', 'agent-send.sh'), { recursive: true });
const stale = '# user tools\n';
writeFileSync(join(home, 'TOOLS.md'), stale);
const result = repairFleetCommsTools(framework, home);
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.reason).toContain('not a regular file');
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
});
it('refuses a pre-existing digest backup whose bytes do not match', () => {
mkdirSync(home, { recursive: true });
const stale = '# user tools\n';
writeFileSync(join(home, 'TOOLS.md'), stale);
const digest = createHash('sha256').update(stale).digest('hex').slice(0, 16);
writeFileSync(join(home, `TOOLS.md.pre-fleet-comms-${digest}.bak`), 'collision\n');
const result = repairFleetCommsTools(framework, home);
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.reason).toContain('backup collision');
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
});
it('rejects a symlink in each installed destination ancestor without external writes', () => {
const cases = [
{ name: 'home', prefix: join(root, 'linked-home'), suffix: '' },
{ name: 'tools', prefix: join(root, 'real-home'), suffix: 'tools' },
{ name: 'tmux', prefix: join(root, 'real-home'), suffix: join('tools', 'tmux') },
];
for (const testCase of cases) {
const external = join(root, `external-${testCase.name}`);
mkdirSync(external, { recursive: true });
const targetHome =
testCase.name === 'home' ? testCase.prefix : join(root, `installed-${testCase.name}`);
if (testCase.name === 'home') {
symlinkSync(external, targetHome);
} else {
mkdirSync(targetHome, { recursive: true });
const linkPath = join(targetHome, testCase.suffix);
mkdirSync(join(linkPath, '..'), { recursive: true });
symlinkSync(external, linkPath);
}
const result = repairFleetCommsTools(framework, targetHome);
expect(result, testCase.name).toMatchObject({ ok: false, changed: false });
expect(result.reason, testCase.name).toContain('symbolic link');
expect(readdirSync(external), testCase.name).toEqual([]);
}
});
it('rolls back the backup and exact TOOLS bytes/mode when helper commit fails', () => {
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
const staleTools = '# user tools\n';
const staleHelper = '#!/bin/sh\nexit 17\n';
writeFileSync(join(home, 'TOOLS.md'), staleTools, { mode: 0o640 });
writeFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), staleHelper, { mode: 0o710 });
const result = repairFleetCommsTools(framework, home, {
beforeCommit(which) {
if (which === 'helper') throw new Error('injected helper commit failure');
},
});
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.backupPath).toBeUndefined();
expect(result.reason).toContain('injected helper commit failure');
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(staleTools);
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
expect(readFileSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 'utf8')).toBe(staleHelper);
expect(statSync(join(home, 'tools', 'tmux', 'agent-send.sh')).mode & 0o777).toBe(0o710);
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
expect(
readdirSync(home).some((name) => name.includes('.repair-')) ||
readdirSync(join(home, 'tools', 'tmux')).some((name) => name.includes('.repair-')),
).toBe(false);
});
it('rolls back initially absent destinations and created directories on commit failure', () => {
const result = repairFleetCommsTools(framework, home, {
beforeCommit(which) {
if (which === 'helper') throw new Error('injected absent helper failure');
},
});
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.reason).toContain('injected absent helper failure');
expect(existsSync(home)).toBe(false);
});
it('does not persist a backup or replacement when backup commit fails', () => {
mkdirSync(home, { recursive: true });
const stale = '# user tools\n';
writeFileSync(join(home, 'TOOLS.md'), stale, { mode: 0o640 });
const result = repairFleetCommsTools(framework, home, {
beforeCommit(which) {
if (which === 'backup') throw new Error('injected backup commit failure');
},
});
expect(result).toMatchObject({ ok: false, changed: false });
expect(readFileSync(join(home, 'TOOLS.md'), 'utf8')).toBe(stale);
expect(statSync(join(home, 'TOOLS.md')).mode & 0o777).toBe(0o640);
expect(readdirSync(home).filter((name) => name.includes('pre-fleet-comms'))).toEqual([]);
});
it('fails before writes when bundled source paths traverse a symlink ancestor', () => {
const external = join(root, 'external-source');
mkdirSync(join(external, 'defaults'), { recursive: true });
mkdirSync(join(external, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(external, 'defaults', 'TOOLS.md'), toolsContent);
writeFileSync(join(external, 'tools', 'tmux', 'agent-send.sh'), helperContent, { mode: 0o755 });
const linkedFramework = join(root, 'linked-framework');
symlinkSync(external, linkedFramework);
const result = repairFleetCommsTools(linkedFramework, home);
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.reason).toContain('symbolic link');
expect(existsSync(home)).toBe(false);
});
});
describe('runFrameworkReseed', () => {

View File

@@ -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;
}
/**