fix(#792): guard fleet roster schema errors
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
Hermes Agent
2026-07-17 15:48:52 -05:00
parent 080f720dfe
commit e0b0bde5ad
3 changed files with 48 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ Make expected missing or malformed fleet roster configuration fail with an actio
- 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading. - 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading.
- 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace. - 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace.
- 2026-07-17: Rebased #818 onto `origin/main` at `9ddc6fbd` (#791 PR3). The added `fleet regen` command had a canonical roster read in its sibling module; it now uses the same missing-roster guard and Commander exit path. Internal NORTH_STAR, preset, and post-write invariant reads remain intentionally unguarded. - 2026-07-17: Rebased #818 onto `origin/main` at `9ddc6fbd` (#791 PR3). The added `fleet regen` command had a canonical roster read in its sibling module; it now uses the same missing-roster guard and Commander exit path. Internal NORTH_STAR, preset, and post-write invariant reads remain intentionally unguarded.
- 2026-07-17: RoR found that semantically invalid v1 documents still escaped as plain `Error` values. `normalizeFleetRosterV1` now preserves each validation message while converting it to `FleetRosterConfigurationError`, so its command callers use the actionable nonzero Commander path.
## Verification ## Verification
@@ -29,6 +30,7 @@ Make expected missing or malformed fleet roster configuration fail with an actio
- `pnpm format:check` — PASS - `pnpm format:check` — PASS
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests) - `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests)
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-regen-command.spec.ts` — PASS (27 tests, including missing canonical roster) - `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-regen-command.spec.ts` — PASS (27 tests, including missing canonical roster)
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts -t "semantically invalid v1 roster"` — RED then PASS; verifies duplicate agent names are reported as `fleet.roster` exit 1 without a stack trace.
- Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage. - Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage.
## Risks / blockers ## Risks / blockers

View File

@@ -214,6 +214,42 @@ describe('fleet roster configuration failures', () => {
'Fleet roster at /srv/mosaic/fleet/roster.yaml is invalid. Fix the file or run `mosaic fleet init --force`.', 'Fleet roster at /srv/mosaic/fleet/roster.yaml is invalid. Fix the file or run `mosaic fleet init --force`.',
); );
}); });
it('reports a semantically invalid v1 roster as an actionable nonzero command error', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
await mkdir(dirname(rosterPath), { recursive: true });
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'agents:',
' - name: canary-pi',
' runtime: pi',
' - name: canary-pi',
' runtime: codex',
].join('\n'),
);
await expect(
buildProgram().parseAsync(['node', 'mosaic', 'fleet', '--mosaic-home', home, 'ps']),
).rejects.toMatchObject({
code: 'fleet.roster',
exitCode: 1,
message: 'Fleet roster has duplicate agent name: canary-pi.',
});
expect(stderrSpy.mock.calls.flat().join('')).toContain(
'Fleet roster has duplicate agent name: canary-pi.',
);
expect(stderrSpy.mock.calls.flat().join('')).not.toMatch(/\n\s+at\s/);
} finally {
stderrSpy.mockRestore();
await rm(home, { recursive: true, force: true });
}
});
}); });
describe('fleet roster parsing', () => { describe('fleet roster parsing', () => {

View File

@@ -197,6 +197,16 @@ export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
} }
export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster { export function normalizeFleetRosterV1(raw: RawFleetRoster): FleetRoster {
try {
return normalizeFleetRosterV1Unchecked(raw);
} catch (error) {
if (error instanceof FleetRosterConfigurationError) throw error;
if (error instanceof Error) throw new FleetRosterConfigurationError(error.message);
throw error;
}
}
function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
assertObject(raw, 'Fleet roster'); assertObject(raw, 'Fleet roster');
assertKnownKeys(raw, 'Fleet roster', [ assertKnownKeys(raw, 'Fleet roster', [
'version', 'version',