fix(fleet): enforce exact comms authority (#787)
This commit was merged in pull request #787.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user