Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
982f4fc8f9 |
@@ -33,6 +33,7 @@ export default tseslint.config(
|
|||||||
'packages/db/vitest.config.ts',
|
'packages/db/vitest.config.ts',
|
||||||
'packages/storage/vitest.config.ts',
|
'packages/storage/vitest.config.ts',
|
||||||
'packages/mosaic/vitest.config.ts',
|
'packages/mosaic/vitest.config.ts',
|
||||||
|
'packages/mosaic/vitest.setup.ts',
|
||||||
'packages/mosaic/__tests__/*.ts',
|
'packages/mosaic/__tests__/*.ts',
|
||||||
'tools/federation-harness/*.ts',
|
'tools/federation-harness/*.ts',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
|||||||
Requires=mosaic-tmux-holder.service
|
Requires=mosaic-tmux-holder.service
|
||||||
After=mosaic-tmux-holder.service
|
After=mosaic-tmux-holder.service
|
||||||
PartOf=mosaic-tmux-holder.service
|
PartOf=mosaic-tmux-holder.service
|
||||||
# Do not attempt a seat before its generated env exists. `install` enables this
|
|
||||||
# unit (WantedBy=default.target) but on a roster-v2 fleet the reconciler owns the
|
|
||||||
# generated env, so between `install` and the first `apply`/`regen --write` there
|
|
||||||
# is a boot window where ExecStart would run against an absent env file and the
|
|
||||||
# launcher would fail the unit. A skipped unit is the honest state for "enabled
|
|
||||||
# but not yet configured"; systemd re-evaluates the condition on every start, so
|
|
||||||
# the seat comes up on the next start once the reconciler has written env.
|
|
||||||
ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated
|
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
|||||||
@@ -1,323 +0,0 @@
|
|||||||
import { execFile } from 'node:child_process';
|
|
||||||
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #1237: the v1-only commands (`ps`, `install`, `install-systemd`, `add`,
|
|
||||||
* `remove`) rejected a roster-v2 fleet outright, so a greenfield v2 box could
|
|
||||||
* never get its units placed. These tests pin the three behaviours that fix
|
|
||||||
* gives it, and the two it deliberately does NOT give it.
|
|
||||||
*
|
|
||||||
* The load-bearing negative is that `install` on v2 writes no generated env:
|
|
||||||
* the reconciler owns that file through projectRosterV2AgentGeneratedEnv, and a
|
|
||||||
* second writer here — necessarily through the v1 mapping — is exactly the
|
|
||||||
* drift the #791 single-SSOT invariant exists to prevent.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const rosterV2 = `
|
|
||||||
version: 2
|
|
||||||
generation: 4
|
|
||||||
transport: tmux
|
|
||||||
tmux:
|
|
||||||
socket_name: mosaic-fleet
|
|
||||||
holder_session: _holder
|
|
||||||
defaults:
|
|
||||||
working_directory: /srv/mosaic
|
|
||||||
runtime: pi
|
|
||||||
runtimes:
|
|
||||||
pi:
|
|
||||||
reset_command: /new
|
|
||||||
agents:
|
|
||||||
- name: coder0
|
|
||||||
alias: Coder 0
|
|
||||||
class: code
|
|
||||||
runtime: pi
|
|
||||||
provider: openai
|
|
||||||
model: gpt-5.6-sol
|
|
||||||
reasoning: high
|
|
||||||
tool_policy: code
|
|
||||||
working_directory: /srv/mosaic
|
|
||||||
persistent_persona: false
|
|
||||||
reset_between_tasks: true
|
|
||||||
lifecycle:
|
|
||||||
enabled: true
|
|
||||||
desired_state: stopped
|
|
||||||
launch:
|
|
||||||
yolo: true
|
|
||||||
- name: coder1
|
|
||||||
alias: Coder 1
|
|
||||||
class: code
|
|
||||||
runtime: pi
|
|
||||||
provider: openai
|
|
||||||
model: gpt-5.6-sol
|
|
||||||
reasoning: medium
|
|
||||||
tool_policy: code
|
|
||||||
working_directory: /srv/other
|
|
||||||
persistent_persona: false
|
|
||||||
reset_between_tasks: true
|
|
||||||
lifecycle:
|
|
||||||
enabled: true
|
|
||||||
desired_state: stopped
|
|
||||||
launch:
|
|
||||||
yolo: true
|
|
||||||
`;
|
|
||||||
|
|
||||||
let tempHome: string | undefined;
|
|
||||||
const savedHome = process.env.HOME;
|
|
||||||
const savedMosaicHome = process.env.MOSAIC_HOME;
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.exitCode = undefined;
|
|
||||||
if (savedHome === undefined) delete process.env.HOME;
|
|
||||||
else process.env.HOME = savedHome;
|
|
||||||
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
|
||||||
else process.env.MOSAIC_HOME = savedMosaicHome;
|
|
||||||
if (tempHome) await rm(tempHome, { recursive: true, force: true });
|
|
||||||
tempHome = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
|
||||||
* anything has been installed, applied or started.
|
|
||||||
*/
|
|
||||||
async function v2Home(): Promise<string> {
|
|
||||||
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
|
||||||
process.env.HOME = tempHome;
|
|
||||||
delete process.env.MOSAIC_HOME;
|
|
||||||
const mosaicHome = join(tempHome, '.config', 'mosaic');
|
|
||||||
for (const directory of ['fleet', 'fleet/agents', 'fleet/roles']) {
|
|
||||||
await mkdir(join(mosaicHome, directory), { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
await writeFile(join(mosaicHome, 'fleet', 'roster.yaml'), rosterV2, { mode: 0o600 });
|
|
||||||
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
return mosaicHome;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stands in for a box where nothing is running: every systemctl and tmux probe
|
|
||||||
* fails the way it does before the holder has ever started. `ps` must survive
|
|
||||||
* this — it is the command an operator reaches for to find out *why* there is
|
|
||||||
* no seat, so it has to report the emptiness rather than fail on it.
|
|
||||||
*/
|
|
||||||
const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult> => {
|
|
||||||
if (command === 'tmux') {
|
|
||||||
return { stdout: '', stderr: 'no server running on /tmp/tmux-1000/mosaic-fleet', exitCode: 1 };
|
|
||||||
}
|
|
||||||
return { stdout: '', stderr: '', exitCode: 1 };
|
|
||||||
};
|
|
||||||
|
|
||||||
function program(runner: CommandRunner = greenfieldRunner): Command {
|
|
||||||
const result = new Command();
|
|
||||||
result.exitOverride();
|
|
||||||
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function capture(): string[] {
|
|
||||||
const lines: string[] = [];
|
|
||||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
|
||||||
lines.push(value);
|
|
||||||
});
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exists(path: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await stat(path);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('mosaic fleet ps — roster v2', (): void => {
|
|
||||||
it('lists every v2 agent on a greenfield box with nothing running, and does not throw', async (): Promise<void> => {
|
|
||||||
await v2Home();
|
|
||||||
const lines = capture();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program().parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
|
|
||||||
const rows = JSON.parse(lines.join('\n')) as {
|
|
||||||
name: string;
|
|
||||||
runtime: string;
|
|
||||||
alias?: string;
|
|
||||||
paneAlive: boolean;
|
|
||||||
source: string;
|
|
||||||
}[];
|
|
||||||
expect(rows.map((row) => row.name).sort()).toEqual(['coder0', 'coder1']);
|
|
||||||
// The v2 roster's per-agent fields must survive the read model, not be
|
|
||||||
// flattened into defaults.
|
|
||||||
expect(rows.every((row) => row.runtime === 'pi')).toBe(true);
|
|
||||||
expect(rows.find((row) => row.name === 'coder0')?.alias).toBe('Coder 0');
|
|
||||||
// Nothing is running, and that is a report, not an error.
|
|
||||||
expect(rows.every((row) => row.paneAlive === false)).toBe(true);
|
|
||||||
expect(rows.every((row) => row.source === 'roster')).toBe(true);
|
|
||||||
expect(process.exitCode ?? 0).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mosaic fleet install — roster v2', (): void => {
|
|
||||||
it('places the tool files and unit templates', async (): Promise<void> => {
|
|
||||||
const mosaicHome = await v2Home();
|
|
||||||
capture();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
|
|
||||||
// Units live in the systemd user dir, not under the Mosaic home.
|
|
||||||
const systemdUserDir = join(tempHome!, '.config', 'systemd', 'user');
|
|
||||||
for (const unit of [
|
|
||||||
'mosaic-tmux-holder.service',
|
|
||||||
'[email protected]',
|
|
||||||
'[email protected]',
|
|
||||||
]) {
|
|
||||||
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
|
||||||
}
|
|
||||||
const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
|
|
||||||
expect(await exists(launcher)).toBe(true);
|
|
||||||
expect((await stat(launcher)).mode & 0o777).toBe(0o755);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
|
||||||
const mosaicHome = await v2Home();
|
|
||||||
capture();
|
|
||||||
|
|
||||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
|
||||||
|
|
||||||
const agentDir = join(mosaicHome, 'fleet', 'agents');
|
|
||||||
expect(await readdir(agentDir)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tells the operator which command does own the env', async (): Promise<void> => {
|
|
||||||
await v2Home();
|
|
||||||
const lines = capture();
|
|
||||||
|
|
||||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
|
||||||
|
|
||||||
expect(lines.join('\n')).toContain('mosaic fleet apply');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('[email protected]', (): void => {
|
|
||||||
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
|
|
||||||
|
|
||||||
/** The single `ConditionPathExists=` value declared by the unit template. */
|
|
||||||
async function conditionPath(): Promise<string> {
|
|
||||||
const unit = await readFile(unitPath, 'utf8');
|
|
||||||
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
|
|
||||||
expect(matches).toHaveLength(1);
|
|
||||||
return matches[0]!.slice('ConditionPathExists='.length).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
|
|
||||||
// The pairing that makes "install writes no env" safe: install enables the
|
|
||||||
// unit (WantedBy=default.target) but does not start it, so without this
|
|
||||||
// condition a reboot between `install` and the first `apply` would run
|
|
||||||
// ExecStart against an absent env file and fail every seat unit.
|
|
||||||
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated');
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The two halves of the guard's *effect*, which no assertion on the literal
|
|
||||||
* string can cover on its own.
|
|
||||||
*
|
|
||||||
* Measured end to end on a real box (canary, 2026-08-16) rather than inferred:
|
|
||||||
* with the condition, `systemctl --user start mosaic-agent@<name>` on an agent
|
|
||||||
* with no generated env returns rc=0, `Result=success`, `ConditionResult=no`,
|
|
||||||
* and journals "skipped, unmet condition check". With the condition removed by
|
|
||||||
* drop-in and nothing else changed, the same start returns rc=1,
|
|
||||||
* `Result=exit-code`, `ExecMainStatus=64`, and the unit enters `failed`.
|
|
||||||
*
|
|
||||||
* systemd is not available in this suite, so these two tests pin the parts
|
|
||||||
* that can drift in code: the condition naming a *different* file than the one
|
|
||||||
* the fleet actually writes, and the launcher quietly becoming tolerant of an
|
|
||||||
* absent env — either of which turns the condition into decoration while the
|
|
||||||
* literal-string assertion above still passes.
|
|
||||||
*/
|
|
||||||
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
|
|
||||||
const mosaicHome = await v2Home();
|
|
||||||
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0');
|
|
||||||
|
|
||||||
// The path an installed fleet actually places for this agent.
|
|
||||||
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
|
|
||||||
await v2Home();
|
|
||||||
await program().parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']);
|
|
||||||
|
|
||||||
// Exactly what ExecStart runs, against the state the condition exists to
|
|
||||||
// catch: unit enabled, reconciler has not written env yet.
|
|
||||||
const launched = await new Promise<{ code: number | null; stderr: string }>((settle) => {
|
|
||||||
const child = execFile(
|
|
||||||
'/bin/bash',
|
|
||||||
[
|
|
||||||
'--noprofile',
|
|
||||||
'--norc',
|
|
||||||
join(tempHome!, '.config', 'mosaic', 'tools', 'fleet', 'start-agent-session.sh'),
|
|
||||||
'coder0',
|
|
||||||
],
|
|
||||||
{ env: { HOME: tempHome!, MOSAIC_AGENT_NAME: 'coder0', PATH: '/usr/bin:/bin' } },
|
|
||||||
(_error, _stdout, stderr) => {
|
|
||||||
settle({ code: child.exitCode, stderr });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(launched.code).not.toBe(0);
|
|
||||||
expect(launched.stderr).toContain('missing-file');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mosaic fleet add / remove — roster v2', (): void => {
|
|
||||||
it('add refuses, and names the two-step v2 sequence instead of inventing defaults', async (): Promise<void> => {
|
|
||||||
await v2Home();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program().parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'add',
|
|
||||||
'coder2',
|
|
||||||
'--runtime',
|
|
||||||
'pi',
|
|
||||||
'--class',
|
|
||||||
'code',
|
|
||||||
]),
|
|
||||||
).rejects.toThrow(/mosaic fleet create[\s\S]*mosaic fleet apply/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('remove refuses, and names delete plus apply', async (): Promise<void> => {
|
|
||||||
await v2Home();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
|
|
||||||
).rejects.toThrow(/mosaic fleet delete coder1[\s\S]*mosaic fleet apply/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: this one passes on the unmodified tree too — there `remove` throws in
|
|
||||||
// the v1 parser, before it can touch anything. It is a regression guard on the
|
|
||||||
// ordering of the new guard clause, not evidence that the fix works.
|
|
||||||
it('refuses BEFORE mutating the roster', async (): Promise<void> => {
|
|
||||||
const mosaicHome = await v2Home();
|
|
||||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
|
||||||
const before = await readFile(rosterPath, 'utf8');
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program().parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder1']),
|
|
||||||
).rejects.toThrow();
|
|
||||||
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toBe(before);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -34,7 +34,6 @@ export {
|
|||||||
resolveInstalledFleetRosterPath,
|
resolveInstalledFleetRosterPath,
|
||||||
} from '../fleet/fleet-roster-v1.js';
|
} from '../fleet/fleet-roster-v1.js';
|
||||||
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
|
||||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
|
||||||
import {
|
import {
|
||||||
registerFleetAgentCrudCommands,
|
registerFleetAgentCrudCommands,
|
||||||
type FleetAgentCrudCommandDeps,
|
type FleetAgentCrudCommandDeps,
|
||||||
@@ -821,7 +820,7 @@ export function buildEnableLingerCommand(user: string): string[] {
|
|||||||
*/
|
*/
|
||||||
export async function enableFleetUnits(
|
export async function enableFleetUnits(
|
||||||
runner: CommandRunner,
|
runner: CommandRunner,
|
||||||
roster: { readonly agents: readonly { readonly name: string }[] },
|
roster: FleetRoster,
|
||||||
opts: { enable?: boolean },
|
opts: { enable?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (opts.enable === false) {
|
if (opts.enable === false) {
|
||||||
@@ -1528,8 +1527,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||||
.action(async (opts: { enable?: boolean }) => {
|
.action(async (opts: { enable?: boolean }) => {
|
||||||
await installFleet(cmd, frameworkRoot);
|
await installFleet(cmd, frameworkRoot);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
const roster = await loadRosterForCommand(cmd);
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1539,8 +1537,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||||
.action(async (opts: { enable?: boolean }) => {
|
.action(async (opts: { enable?: boolean }) => {
|
||||||
await installFleet(cmd, frameworkRoot);
|
await installFleet(cmd, frameworkRoot);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
const roster = await loadRosterForCommand(cmd);
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1691,9 +1688,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.action(async (opts: { json?: boolean }) => {
|
.action(async (opts: { json?: boolean }) => {
|
||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
// ps only reads, so it takes the version-agnostic read model rather than
|
const roster = await loadRosterForCommand(cmd);
|
||||||
// the v1 parser, which rejects a v2 roster outright.
|
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
const { tenant_id, host } = getDefaultTenantAndHost();
|
const { tenant_id, host } = getDefaultTenantAndHost();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
|
|
||||||
@@ -1913,16 +1908,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
start: boolean;
|
start: boolean;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
if (await usesRosterV2ControlPlane(cmd)) {
|
|
||||||
// command.error, not a bare throw: this is operator guidance, and a
|
|
||||||
// bare throw reaches the top level uncaught and prints it under a Node
|
|
||||||
// stack trace. Measured on canary — the message is the whole point of
|
|
||||||
// the refusal, so it has to arrive readable.
|
|
||||||
cmd.error(rosterV2MutationGuidance('add', 'create', name), {
|
|
||||||
code: 'fleet.roster-v2',
|
|
||||||
exitCode: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
|
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
|
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
|
||||||
@@ -1988,12 +1973,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.description('Remove an agent from the fleet roster')
|
.description('Remove an agent from the fleet roster')
|
||||||
.option('--keep-files', 'Skip deleting env and heartbeat files')
|
.option('--keep-files', 'Skip deleting env and heartbeat files')
|
||||||
.action(async (name: string, opts: { keepFiles?: boolean }) => {
|
.action(async (name: string, opts: { keepFiles?: boolean }) => {
|
||||||
if (await usesRosterV2ControlPlane(cmd)) {
|
|
||||||
cmd.error(rosterV2MutationGuidance('remove', 'delete', name), {
|
|
||||||
code: 'fleet.roster-v2',
|
|
||||||
exitCode: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
@@ -2352,9 +2331,7 @@ export function registerFleetAgentCommands(
|
|||||||
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
||||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||||
// Read model first: every file this function places is roster-independent, and
|
const roster = await loadRosterForCommand(cmd);
|
||||||
// the v1 parser would reject a v2 roster before any of them were written.
|
|
||||||
const roster = await loadRosterReadModel(cmd);
|
|
||||||
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
||||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||||
@@ -2414,30 +2391,16 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
join(activePaths.systemdUserDir, '[email protected]'),
|
join(activePaths.systemdUserDir, '[email protected]'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// On roster v2 the reconciler owns the generated env: `apply` writes it and
|
for (const agent of roster.agents) {
|
||||||
// `regen` rebuilds it, both from projectRosterV2AgentGeneratedEnv. Writing it
|
|
||||||
// here too — necessarily through the v1 mapping — would be the third writer of
|
|
||||||
// one file and would break the #791 single-SSOT invariant. So v2 gets the tool
|
|
||||||
// files and the units, and nothing else.
|
|
||||||
if (roster.version === 2) {
|
|
||||||
console.log(
|
|
||||||
`Installed fleet tools and systemd units for ${roster.agents.length} agent(s). ` +
|
|
||||||
`Generated env is owned by the reconciler on roster v2 — run: mosaic fleet apply --expected-generation <n>`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const v1Roster = await loadRosterForCommand(cmd);
|
|
||||||
for (const agent of v1Roster.agents) {
|
|
||||||
await writeAgentEnvironmentProjection({
|
await writeAgentEnvironmentProjection({
|
||||||
mosaicHome: activePaths.mosaicHome,
|
mosaicHome: activePaths.mosaicHome,
|
||||||
agentEnvDir: activePaths.agentEnvDir,
|
agentEnvDir: activePaths.agentEnvDir,
|
||||||
agentName: agent.name,
|
agentName: agent.name,
|
||||||
generated: generateAgentEnvValues(v1Roster, agent),
|
generated: generateAgentEnvValues(roster, agent),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Installed fleet files for ${v1Roster.agents.length} agent(s).`);
|
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||||
@@ -2464,77 +2427,6 @@ async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `add`/`remove` and `create`/`delete` are not two spellings of one operation.
|
|
||||||
* The v1 pair edits the roster *and* drives systemd; the v2 pair is documented
|
|
||||||
* as changing desired state "without runtime actions", leaving convergence to
|
|
||||||
* `apply`. `add` also collects four fields where a v2 agent requires eleven, so
|
|
||||||
* routing it to `create` would mean inventing provider, alias, reasoning and
|
|
||||||
* tool-policy defaults on the operator's behalf. Refusing with the real command
|
|
||||||
* is honest; silently guessing an agent's provider is not.
|
|
||||||
*/
|
|
||||||
function rosterV2MutationGuidance(
|
|
||||||
v1Command: 'add' | 'remove',
|
|
||||||
v2Command: 'create' | 'delete',
|
|
||||||
name: string,
|
|
||||||
): string {
|
|
||||||
const target = v2Command === 'delete' ? ` ${name}` : '';
|
|
||||||
return (
|
|
||||||
`mosaic fleet ${v1Command} does not operate on a roster-v2 fleet. ` +
|
|
||||||
`Roster v2 separates desired state from convergence:\n` +
|
|
||||||
` 1. mosaic fleet ${v2Command}${target} --expected-generation <current> ` +
|
|
||||||
`${v2Command === 'create' ? "--agent '<json>' " : ''}` +
|
|
||||||
`(edits the roster only)\n` +
|
|
||||||
` 2. mosaic fleet apply --expected-generation <new> (converges systemd and tmux)\n` +
|
|
||||||
`Read the current generation with: mosaic fleet status`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The read-only fields shared by roster v1 and v2, for the commands that only
|
|
||||||
* ever *read* the roster (`ps`, and unit enablement inside `install`).
|
|
||||||
*
|
|
||||||
* This is deliberately NOT a v2→v1 downshift. A downshifted `FleetRoster` would
|
|
||||||
* be accepted by `generateAgentEnvValues`, and that would make a third writer of
|
|
||||||
* `fleet/agents/<name>.env.generated` — through the v1 mapping — breaking the
|
|
||||||
* #791 single-SSOT invariant that {@link projectRosterV2AgentGeneratedEnv} is
|
|
||||||
* documented to hold. Keeping the read model this small makes that misuse
|
|
||||||
* impossible: there is nothing here to write a roster or an env file back from.
|
|
||||||
*/
|
|
||||||
interface FleetRosterReadModel {
|
|
||||||
readonly version: 1 | 2;
|
|
||||||
readonly tmux: { readonly socketName: string; readonly holderSession: string };
|
|
||||||
readonly agents: readonly {
|
|
||||||
readonly name: string;
|
|
||||||
readonly alias?: string;
|
|
||||||
readonly runtime: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads either roster version into the shared read-only view. */
|
|
||||||
async function loadRosterReadModel(cmd: Command): Promise<FleetRosterReadModel> {
|
|
||||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
|
||||||
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
|
||||||
if (!(await usesRosterV2ControlPlane(cmd))) {
|
|
||||||
const v1 = await loadRosterAtPath(cmd, path);
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
tmux: { socketName: v1.tmux.socketName, holderSession: v1.tmux.holderSession },
|
|
||||||
agents: v1.agents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const v2 = parseRosterV2(await readFleetRosterText(path), 'yaml');
|
|
||||||
return {
|
|
||||||
version: 2,
|
|
||||||
tmux: { socketName: v2.tmux.socketName, holderSession: v2.tmux.holderSession },
|
|
||||||
agents: v2.agents,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
reportFleetRosterConfigurationError(cmd, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRosterFromAgentCommand(
|
async function loadRosterFromAgentCommand(
|
||||||
command: Command,
|
command: Command,
|
||||||
mosaicHomeOverride?: string,
|
mosaicHomeOverride?: string,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { isHostLeaseVariable, scrubHostLeaseEnv } from './host-lease-env.js';
|
||||||
|
|
||||||
|
describe('host lease environment scrubbing', () => {
|
||||||
|
test('removes every lease variable and reports what it removed', () => {
|
||||||
|
const environment = {
|
||||||
|
MOSAIC_LEASE_GENERATION_FILE: '/run/user/1001/mosaic-lease/generation-abc.state',
|
||||||
|
MOSAIC_LEASE_SESSION_ID: 'a'.repeat(64),
|
||||||
|
MOSAIC_LEASE_BROKER_SOCKET: '/run/user/1001/mosaic-lease/broker.sock',
|
||||||
|
MOSAIC_LEASE_RUNTIME: 'claude',
|
||||||
|
MOSAIC_RUNTIME_GENERATION: '388',
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
MOSAIC_AGENT_NAME: 'fred',
|
||||||
|
} as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
expect(scrubHostLeaseEnv(environment)).toEqual([
|
||||||
|
'MOSAIC_LEASE_BROKER_SOCKET',
|
||||||
|
'MOSAIC_LEASE_GENERATION_FILE',
|
||||||
|
'MOSAIC_LEASE_RUNTIME',
|
||||||
|
'MOSAIC_LEASE_SESSION_ID',
|
||||||
|
'MOSAIC_RUNTIME_GENERATION',
|
||||||
|
]);
|
||||||
|
expect(environment).toEqual({ PATH: '/usr/bin', MOSAIC_AGENT_NAME: 'fred' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a lease variable added later is scrubbed without being listed anywhere', () => {
|
||||||
|
// The prefix rule is the point: this is the case a hand-maintained list would miss.
|
||||||
|
const environment = { MOSAIC_LEASE_SOMETHING_NEW: 'x' } as NodeJS.ProcessEnv;
|
||||||
|
expect(scrubHostLeaseEnv(environment)).toEqual(['MOSAIC_LEASE_SOMETHING_NEW']);
|
||||||
|
expect(environment).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves unrelated variables alone', () => {
|
||||||
|
const environment = {
|
||||||
|
MOSAIC_AGENT_NAME: 'fred',
|
||||||
|
MOSAIC_HOME: '/home/fred/.mosaic',
|
||||||
|
HOME: '/home/fred',
|
||||||
|
} as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
|
expect(scrubHostLeaseEnv(environment)).toEqual([]);
|
||||||
|
expect(environment).toEqual({
|
||||||
|
MOSAIC_AGENT_NAME: 'fred',
|
||||||
|
MOSAIC_HOME: '/home/fred/.mosaic',
|
||||||
|
HOME: '/home/fred',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifies by prefix, not by an exact list', () => {
|
||||||
|
expect(isHostLeaseVariable('MOSAIC_LEASE_ANYTHING')).toBe(true);
|
||||||
|
expect(isHostLeaseVariable('MOSAIC_RUNTIME_GENERATION')).toBe(true);
|
||||||
|
expect(isHostLeaseVariable('MOSAIC_RUNTIME')).toBe(false);
|
||||||
|
expect(isHostLeaseVariable('LEASE_MOSAIC_X')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wiring check. On a clean checkout or in CI these variables are unset, so this
|
||||||
|
// passes whether or not vitest.setup.ts is registered -- it is worth little there and
|
||||||
|
// is not claimed to be. Its value is inside a Mosaic-managed agent seat, where the
|
||||||
|
// variables ARE set and this is the assertion that catches the setup file being
|
||||||
|
// dropped from vitest.config.ts. That is the environment the leak was found in.
|
||||||
|
test('the suite does not run with the host lease identity in scope', () => {
|
||||||
|
expect(Object.keys(process.env).filter(isHostLeaseVariable)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Remove the host's live lease identity from an environment before tests run.
|
||||||
|
*
|
||||||
|
* The lease specs start their own broker on a private socket and then spawn the real
|
||||||
|
* hook scripts against it, building each child's environment as `{ ...process.env, <the
|
||||||
|
* few vars this case cares about> }`. That spread is the problem: when the suite runs
|
||||||
|
* inside a Mosaic-managed agent seat, `process.env` already carries that seat's real
|
||||||
|
* lease identity, and the parts the spread does not override survive into the child.
|
||||||
|
*
|
||||||
|
* `MOSAIC_LEASE_GENERATION_FILE` is the one that bites. `read_runtime_generation()`
|
||||||
|
* prefers that file over `MOSAIC_RUNTIME_GENERATION`, so a case that carefully sets
|
||||||
|
* `MOSAIC_RUNTIME_GENERATION: '1'` is silently overruled by the host's generation
|
||||||
|
* counter -- which on a long-lived seat is in the hundreds. The revoke client reads it,
|
||||||
|
* sends it, and the test broker advances the session to that generation. Every later
|
||||||
|
* `authorize` in the case sends generation 1, is now behind, and is denied with
|
||||||
|
* `STALE_GENERATION` instead of the `MUTATOR_UNVERIFIED` the case asserts. The gate
|
||||||
|
* still denies, so this is not a hole in the product -- but it turns four acceptance
|
||||||
|
* tests red for a reason that has nothing to do with the code under test.
|
||||||
|
*
|
||||||
|
* It only reproduces inside a managed seat. On a clean checkout or in CI these vars are
|
||||||
|
* unset, the suite is green, and the leak is invisible -- which is why it survived.
|
||||||
|
*
|
||||||
|
* Scrubbing by prefix rather than by an explicit list is deliberate: any lease variable
|
||||||
|
* added later leaks by exactly the same route, and a list would have to be remembered.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HOST_LEASE_PREFIX = 'MOSAIC_LEASE_';
|
||||||
|
const HOST_LEASE_EXTRA = ['MOSAIC_RUNTIME_GENERATION'];
|
||||||
|
|
||||||
|
export function isHostLeaseVariable(name: string): boolean {
|
||||||
|
return name.startsWith(HOST_LEASE_PREFIX) || HOST_LEASE_EXTRA.includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes the host lease variables from `environment`; returns the names removed. */
|
||||||
|
export function scrubHostLeaseEnv(environment: NodeJS.ProcessEnv): string[] {
|
||||||
|
const removed = Object.keys(environment).filter(isHostLeaseVariable);
|
||||||
|
for (const name of removed) {
|
||||||
|
delete environment[name];
|
||||||
|
}
|
||||||
|
return removed.sort();
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
testTimeout: 30_000,
|
testTimeout: 30_000,
|
||||||
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
include: ['src/commands/skill.ts', 'src/lease-broker/broker-test-client.ts'],
|
include: ['src/commands/skill.ts', 'src/lease-broker/broker-test-client.ts'],
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { scrubHostLeaseEnv } from './src/test-support/host-lease-env.js';
|
||||||
|
|
||||||
|
// Runs before every spec file in this package. See src/test-support/host-lease-env.ts
|
||||||
|
// for why the host's lease identity must not reach a spawned hook process.
|
||||||
|
scrubHostLeaseEnv(process.env);
|
||||||
Reference in New Issue
Block a user