Compare commits

..
Author SHA1 Message Date
fred 982f4fc8f9 test(lease): stop the host's lease identity leaking into spawned hooks
Four cases in mutator-gate.acceptance.spec.ts fail whenever the suite runs
inside a Mosaic-managed agent seat, and pass everywhere else. They are not a
product defect: the gate denies in all four, it just denies for the wrong
reason.

The specs build each spawned hook's environment as `{ ...process.env, <the few
vars the case sets> }`. Inside a managed seat, process.env already carries that
seat's live lease identity, and whatever the spread does not override survives
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 -- 388 on the seat this was found on. The revoke
client reads 388 and sends it, the test broker advances the session to that
generation, and every later authorize() in the case sends generation 1, is now
behind, and is denied STALE_GENERATION instead of the asserted
MUTATOR_UNVERIFIED. The fourth failure (runtime gate status 2 rather than 0) is
the same cause.

Fixed once, centrally: a vitest setupFile scrubs the host lease variables from
process.env before any spec in the package runs, so all twelve spread sites are
covered and a thirteenth cannot reintroduce it. Scrubbing is by prefix rather
than by an explicit list, because a lease variable added later leaks by exactly
the same route and a list would have to be remembered.

Measured, not assumed. Red on origin/next: 4 failed / 16 passed. The same spec
re-run with only those five variables stripped and no code change: 20/20. With
this commit: typecheck clean, package build clean, 1537 tests passed across 85
files, 0 failed. Falsified by unregistering the setup file, which turns the
wiring assertion red.

The eslint change is mechanical: the project service needs root-level config
files listed in allowDefaultProject, which already carries the sibling
packages/mosaic/vitest.config.ts.

Worth recording why it lasted: on a clean checkout and in CI these variables are
unset, so the suite is green and the leak is invisible. It only reproduces in
the one environment nobody runs the suite in.
2026-08-15 13:28:39 -05:00
8 changed files with 120 additions and 447 deletions
+1
View File
@@ -33,6 +33,7 @@ export default tseslint.config(
'packages/db/vitest.config.ts',
'packages/storage/vitest.config.ts',
'packages/mosaic/vitest.config.ts',
'packages/mosaic/vitest.setup.ts',
'packages/mosaic/__tests__/*.ts',
'tools/federation-harness/*.ts',
],
@@ -4,14 +4,6 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack
Requires=mosaic-tmux-holder.service
After=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]
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);
});
});
+8 -116
View File
@@ -34,7 +34,6 @@ export {
resolveInstalledFleetRosterPath,
} from '../fleet/fleet-roster-v1.js';
export type { FleetAgent, FleetRoster } from '../fleet/fleet-roster-v1.js';
import { parseRosterV2 } from '../fleet/roster-v2.js';
import {
registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps,
@@ -821,7 +820,7 @@ export function buildEnableLingerCommand(user: string): string[] {
*/
export async function enableFleetUnits(
runner: CommandRunner,
roster: { readonly agents: readonly { readonly name: string }[] },
roster: FleetRoster,
opts: { enable?: boolean },
): Promise<void> {
if (opts.enable === false) {
@@ -1528,8 +1527,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot);
// Unit enablement needs agent names only, so it reads either version.
const roster = await loadRosterReadModel(cmd);
const roster = await loadRosterForCommand(cmd);
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')
.action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot);
// Unit enablement needs agent names only, so it reads either version.
const roster = await loadRosterReadModel(cmd);
const roster = await loadRosterForCommand(cmd);
await enableFleetUnits(runner, roster, opts);
});
@@ -1691,9 +1688,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.action(async (opts: { json?: boolean }) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
// ps only reads, so it takes the version-agnostic read model rather than
// the v1 parser, which rejects a v2 roster outright.
const roster = await loadRosterReadModel(cmd);
const roster = await loadRosterForCommand(cmd);
const { tenant_id, host } = getDefaultTenantAndHost();
const nowMs = Date.now();
@@ -1913,16 +1908,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
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)) {
throw new Error(
`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')
.option('--keep-files', 'Skip deleting env and heartbeat files')
.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 activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
@@ -2352,9 +2331,7 @@ export function registerFleetAgentCommands(
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
// Read model first: every file this function places is roster-independent, and
// the v1 parser would reject a v2 roster before any of them were written.
const roster = await loadRosterReadModel(cmd);
const roster = await loadRosterForCommand(cmd);
await ensureFleetHolderIdentity(activePaths.mosaicHome);
await mkdir(activePaths.fleetToolsDir, { 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]'),
);
// On roster v2 the reconciler owns the generated env: `apply` writes it and
// `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) {
for (const agent of roster.agents) {
await writeAgentEnvironmentProjection({
mosaicHome: activePaths.mosaicHome,
agentEnvDir: activePaths.agentEnvDir,
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> {
@@ -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(
command: Command,
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();
}
+1
View File
@@ -5,6 +5,7 @@ export default defineConfig({
globals: true,
environment: 'node',
testTimeout: 30_000,
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',
include: ['src/commands/skill.ts', 'src/lease-broker/broker-test-client.ts'],
+5
View File
@@ -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);