feat(fleet): harden v1 migration preview
Some checks failed
ci/woodpecker/pr/ci Pipeline failed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jarvis
2026-07-16 00:57:33 -05:00
parent 0e814324ca
commit 04891422b3
14 changed files with 477 additions and 82 deletions

View File

@@ -60,7 +60,8 @@ rollback; those gates belong to FCM-M4-002. See [v1-to-v2 preview](./v1-to-v2.md
## Running the guard ## Running the guard
```bash ```bash
pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts pnpm --filter @mosaicstack/mosaic test -- v1-v2-migration.spec.ts \
-t "validates all 13 shipped artifacts and executes ready previews for every v1 fixture"
``` ```
The guard is intentionally limited to shipped assets and validation. It does not generate The guard is intentionally limited to shipped assets and validation. It does not generate

View File

@@ -17,7 +17,9 @@ mosaic fleet migrate-v1 preview \
``` ```
The command emits one JSON object and exits nonzero when the preview is blocked, including when any of The command emits one JSON object and exits nonzero when the preview is blocked, including when any of
`--source`, `--decisions`, or `--observations` is missing. Decision and observation JSON is validated fail-closed: unknown fields, malformed values, and records for non-local `--source`, `--decisions`, or `--observations` is omitted, passed without a path value, or passed an empty
path value. These request-shape failures are reported before any input file is read. Decision and
observation JSON is validated fail-closed: unknown fields, malformed values, and records for non-local
agents are rejected. Decisions must supply a positive v2 `generation`, a reviewed `fleetHost` whenever agents are rejected. Decisions must supply a positive v2 `generation`, a reviewed `fleetHost` whenever
v1 agents include `host` or `ssh`, explicit `defaultRuntime`, and per-local-agent provider, model, v1 agents include `host` or `ssh`, explicit `defaultRuntime`, and per-local-agent provider, model,
reasoning, enabled state, and launch policy. A supported socket field declared by v1 remains authoritative, reasoning, enabled state, and launch policy. A supported socket field declared by v1 remains authoritative,
@@ -42,9 +44,9 @@ be marked disabled. Observed-stopped agents always remain stopped.
## Field disposition ## Field disposition
| v1 field | v2 disposition | | v1 field | v2 disposition |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `version`, `transport`, `tmux`, `defaults`, `runtimes` | Inventoried and structurally compiled; omitted values retain v1 defaults, while present-empty holder/work-directory/reset values block for explicit disposition | | `version`, `transport`, `tmux`, `defaults`, `runtimes` | Inventoried and structurally compiled; omitted runtimes retain v1 built-in defaults, while each explicitly declared runtime without a reset field follows the production v1 `/clear` fallback; present-empty holder/work-directory/reset values block |
| agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition, and `~` stays canonical while expanding only for environment preflight | | agent `name`, `alias`, `runtime`, working directory, persona/reset flags | Copied or explicitly defaulted only when absent; present-empty alias/work-directory values block for explicit disposition. Canonical `~`/`~/...` values stay unchanged in roster evidence and traversal-free forms expand only at the shared production environment-projection boundary before unchanged absolute-path validation |
| `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference | | `provider`, `model_hint`, `reasoning_level` | Explicit provider/model/reasoning decisions; no model-hint inference |
| `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation | | `class`, `tool_policy` | Only approved aliases canonicalize automatically; other classes require explicit preserve/replace disposition and shared-resolver validation |
| `kickstart_template` | No v2 field; explicit inventory-only disposition required | | `kickstart_template` | No v2 field; explicit inventory-only disposition required |
@@ -67,9 +69,10 @@ baseline-plus-`roles.local` resolver after the candidate is compiled by the exis
Ready output includes source and candidate SHA-256 identities, value-free field inventory, excluded Ready output includes source and candidate SHA-256 identities, value-free field inventory, excluded
remote/connector entries, explicit environment dispositions with sanitized diagnostics, and the lifecycle remote/connector entries, explicit environment dispositions with sanitized diagnostics, and the lifecycle
evidence used for each local candidate. Canonical evidence ordering compares Unicode code points directly evidence used for each local candidate. Canonical lifecycle and remote-exclusion evidence ordering compares
and does not depend on process locale. Recovery is marked non-executable and assigns the executable gate to Unicode code points directly and does not depend on source-agent order or process locale. Source field
FCM-M4-002. inventory remains position-addressed evidence of the exact input. Recovery is marked non-executable and
assigns the executable gate to FCM-M4-002.
Before any later cutover, preserve these artifacts: Before any later cutover, preserve these artifacts:

View File

@@ -56,17 +56,22 @@ Migration rules and redaction are critical data-mutation/security logic, so test
- Remediated immutable-review socket-presence blocker: both `socket_name` and `socketName` are field-presence-aware, so explicit empty/default-server declarations remain authoritative and incompatible named decisions block rather than replacing them. - Remediated immutable-review socket-presence blocker: both `socket_name` and `socketName` are field-presence-aware, so explicit empty/default-server declarations remain authoritative and incompatible named decisions block rather than replacing them.
- Remediated the replacement-tree blockers: the shared v2 compiler and reconciler accept an explicit empty socket as literal default-server identity; present-empty holder session, default/agent work directory, runtime reset command, and alias values block rather than defaulting; missing preview inputs emit one stable blocked JSON object with non-zero status. Snake/camel aliases and whitespace-only input have adversarial coverage. - Remediated the replacement-tree blockers: the shared v2 compiler and reconciler accept an explicit empty socket as literal default-server identity; present-empty holder session, default/agent work directory, runtime reset command, and alias values block rather than defaulting; missing preview inputs emit one stable blocked JSON object with non-zero status. Snake/camel aliases and whitespace-only input have adversarial coverage.
- Remediated the subsequent authority blockers: each present-empty CLI path emits exactly one stable blocked JSON object with exit 1 before file reads, and reconciler `start`/`restart` or desired-state `apply`/`reconcile` fail closed before fixed `mosaic-fleet` systemd services can act on a default-server roster. - Remediated the subsequent authority blockers: each present-empty CLI path emits exactly one stable blocked JSON object with exit 1 before file reads, and reconciler `start`/`restart` or desired-state `apply`/`reconcile` fail closed before fixed `mosaic-fleet` systemd services can act on a default-server roster.
- Remediated committed-head review blockers: explicitly declared empty runtime objects use the production v1 `/clear` reset fallback while omitted `pi` retains `/new`; lifecycle observations are sorted by canonical agent name; and bare CLI path flags reach preview validation, emit one stable blocked JSON object with exit 1, and perform zero reads. Built production-CLI subprocess tests cover all three bare flags.
- Remediated late-audit blockers: the documented M4 guard invokes the 13-artifact validator and all seven v1 previews; canonical `~`/`~/...` workdirs remain unchanged in migration evidence and traversal-free forms expand at the shared production projection boundary while ordinary relative and home-relative traversal paths remain rejected; remote inventory and exclusion evidence sort canonically; and every default-server lifecycle-mutating reconciler path fails before observation, projection preparation/application, or fixed-unit effects. Explicit regressions preserve `plan`, `status`, `doctor`, and `verify` as observational default-server commands.
- Remediated exact-tree traversal review: `~/../escape` and `~/src/../../escape` remain unexpanded and fail the unchanged shared `unsafe-path` validation. Both the shared generated-environment boundary and the production v1 environment caller have red-first regressions, preventing earlier caller normalization from bypassing the boundary.
## Verification evidence ## Verification evidence
- Focused migration/compiler/environment/reconciler/CLI: 6 files, 138 tests passed. - Focused migration/compiler/environment/reconciler/CLI: 8 files, 372 tests passed.
- Full `@mosaicstack/mosaic`: 59 files, 883 tests passed. - Documented 13-artifact guard: 1 matching test passed and executed all seven v1 previews.
- Full `@mosaicstack/mosaic`: 59 files, 902 tests passed.
- Workspace build: 23 tasks passed. - Workspace build: 23 tasks passed.
- Root typecheck: 42 tasks passed. - Root typecheck: 42 tasks passed.
- Root lint: 23 tasks passed. - Root lint: 23 tasks passed.
- Root format check and `git diff --check`: passed. - Root format check and `git diff --check`: passed.
- Exact temporary-index synthetic tree includes every tracked and untracked changed path; immutable SHA, path count, and index path are recorded in the frozen handoff. - Built production CLI: canonical `~/src` remains in ready roster/YAML evidence; generated projection preflight succeeds with no blockers; a bare path flag emits one blocked JSON object, exit 1, and no stderr.
- Independent review: previous findings were remediated; per coordinator direction, stop before launching review of the next frozen tree. - Independent high-effort late-audit review: no blocker remained in the four assigned repair surfaces; separate exact-tree security audits found no qualifying newly introduced vulnerability.
- Exact temporary-index synthetic tree includes every tracked changed path; immutable SHA and duplicate reconstruction are recorded in the final handoff.
## Risks / blockers ## Risks / blockers

View File

@@ -1,3 +1,5 @@
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { Command } from 'commander'; import { Command } from 'commander';
import { registerBrainCommand } from '@mosaicstack/brain'; import { registerBrainCommand } from '@mosaicstack/brain';
@@ -16,6 +18,8 @@ import { registerConfigCommand } from './commands/config.js';
// without throwing. This is the "mosaic <cmd> --help exits 0" gate that // without throwing. This is the "mosaic <cmd> --help exits 0" gate that
// guards the sub-package CLI surface (CU-05-01..08) from silent breakage. // guards the sub-package CLI surface (CU-05-01..08) from silent breakage.
const CLI_PATH = fileURLToPath(new URL('../dist/cli.js', import.meta.url));
const REGISTRARS: Array<[string, (program: Command) => void]> = [ const REGISTRARS: Array<[string, (program: Command) => void]> = [
['auth', registerAuthCommand], ['auth', registerAuthCommand],
['brain', registerBrainCommand], ['brain', registerBrainCommand],
@@ -46,6 +50,40 @@ describe('sub-package CLI smoke (CU-05-10)', () => {
}); });
} }
it.each(['source', 'decisions', 'observations'] as const)(
'production CLI emits one blocked JSON object for bare --%s',
(bareOption) => {
const args = [
CLI_PATH,
'fleet',
'migrate-v1',
'preview',
'--source=source',
'--decisions=decisions',
'--observations=observations',
];
args[args.findIndex((argument) => argument.startsWith(`--${bareOption}=`))] =
`--${bareOption}`;
const result = spawnSync(process.execPath, args, { encoding: 'utf8' });
const outputLines = result.stdout.trim().split('\n');
expect(result.status).toBe(1);
expect(result.stderr).toBe('');
expect(outputLines).toHaveLength(1);
expect(JSON.parse(outputLines[0]!)).toEqual({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${bareOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
},
);
it('all nine sub-package commands coexist on a single program', () => { it('all nine sub-package commands coexist on a single program', () => {
const program = new Command(); const program = new Command();
for (const [, register] of REGISTRARS) register(program); for (const [, register] of REGISTRARS) register(program);

View File

@@ -116,6 +116,55 @@ describe('fleet migrate-v1 preview command', (): void => {
expect(setExitCode).toHaveBeenCalledWith(1); expect(setExitCode).toHaveBeenCalledWith(1);
}); });
it.each(['source', 'decisions', 'observations'] as const)(
'emits one stable blocked JSON result when bare --%s has no value',
async (bareOption) => {
const printJson = vi.fn();
const setExitCode = vi.fn();
const readText = vi.fn();
const writeErr = vi.fn();
const program = new Command().exitOverride();
program.configureOutput({ writeErr, writeOut: vi.fn() });
const fleet = program.command('fleet').option('--mosaic-home <path>', '', '/unused');
registerFleetMigrationCommand(fleet, {
mosaicHome: '/unused',
readText,
printJson,
setExitCode,
});
const args = [
'node',
'test',
'fleet',
'migrate-v1',
'preview',
'--source=source',
'--decisions=decisions',
'--observations=observations',
];
args[args.findIndex((argument) => argument.startsWith(`--${bareOption}=`))] =
`--${bareOption}`;
await expect(program.parseAsync(args)).resolves.toBe(program);
expect(printJson).toHaveBeenCalledTimes(1);
expect(printJson).toHaveBeenCalledWith({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${bareOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
expect(setExitCode).toHaveBeenCalledTimes(1);
expect(setExitCode).toHaveBeenCalledWith(1);
expect(readText).not.toHaveBeenCalled();
expect(writeErr).not.toHaveBeenCalled();
},
);
it.each(['source', 'decisions', 'observations'] as const)( it.each(['source', 'decisions', 'observations'] as const)(
'emits one stable blocked JSON result when --%s is present-empty', 'emits one stable blocked JSON result when --%s is present-empty',
async (emptyOption) => { async (emptyOption) => {

View File

@@ -17,9 +17,9 @@ export interface FleetMigrationCommandDeps {
} }
interface PreviewOptions { interface PreviewOptions {
readonly source?: string; readonly source?: string | boolean;
readonly decisions?: string; readonly decisions?: string | boolean;
readonly observations?: string; readonly observations?: string | boolean;
} }
/** Registers preview-only v1 migration. This command has no mutation verbs or runners. */ /** Registers preview-only v1 migration. This command has no mutation verbs or runners. */
@@ -32,9 +32,9 @@ export function registerFleetMigrationCommand(
.description('Preview a field-complete v1-to-v2 roster migration') .description('Preview a field-complete v1-to-v2 roster migration')
.command('preview') .command('preview')
.description('Compile migration evidence without writing files or changing runtimes') .description('Compile migration evidence without writing files or changing runtimes')
.option('--source <path>', 'v1 roster YAML or JSON') .option('--source [path]', 'v1 roster YAML or JSON')
.option('--decisions <path>', 'explicit migration decisions JSON') .option('--decisions [path]', 'explicit migration decisions JSON')
.option('--observations <path>', 'reviewed lifecycle observations JSON') .option('--observations [path]', 'reviewed lifecycle observations JSON')
.action(async (options: PreviewOptions): Promise<void> => { .action(async (options: PreviewOptions): Promise<void> => {
const readText = deps.readText ?? ((path: string): Promise<string> => readFile(path, 'utf8')); const readText = deps.readText ?? ((path: string): Promise<string> => readFile(path, 'utf8'));
const printJson = const printJson =
@@ -58,7 +58,24 @@ export function registerFleetMigrationCommand(
setExitCode(1); setExitCode(1);
return; return;
} }
const emptyOption = requiredOptionNames.find((name) => options[name]?.trim() === ''); const missingValueOption = requiredOptionNames.find((name) => options[name] === true);
if (missingValueOption !== undefined) {
printJson({
status: 'blocked',
blockers: [
{
code: 'missing-migration-preview-option-value',
path: `request.${missingValueOption}`,
detail: 'Required migration preview option value is missing.',
},
],
});
setExitCode(1);
return;
}
const emptyOption = requiredOptionNames.find(
(name) => typeof options[name] === 'string' && options[name].trim() === '',
);
if (emptyOption !== undefined) { if (emptyOption !== undefined) {
printJson({ printJson({
status: 'blocked', status: 'blocked',
@@ -77,9 +94,9 @@ export function registerFleetMigrationCommand(
const decisionsPath = options.decisions; const decisionsPath = options.decisions;
const observationsPath = options.observations; const observationsPath = options.observations;
if ( if (
sourcePath === undefined || typeof sourcePath !== 'string' ||
decisionsPath === undefined || typeof decisionsPath !== 'string' ||
observationsPath === undefined typeof observationsPath !== 'string'
) { ) {
throw new Error('Validated migration preview options became unavailable.'); throw new Error('Validated migration preview options became unavailable.');
} }

View File

@@ -198,6 +198,26 @@ describe('fleet roster parsing', () => {
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi'); expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
}); });
it('uses /clear for an explicitly declared empty pi runtime config', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml');
await writeFile(
rosterPath,
[
'version: 1',
'transport: tmux',
'runtimes:',
' pi: {}',
'agents:',
' - name: canary-pi',
' runtime: pi',
].join('\n'),
);
const loaded = await loadFleetRoster(rosterPath);
expect(loaded.runtimes.pi?.resetCommand).toBe('/clear');
});
it('accepts optional agent alias and provider metadata without requiring them', async () => { it('accepts optional agent alias and provider metadata without requiring them', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml'); const rosterPath = join(cleanup, 'roster.yaml');
@@ -271,6 +291,32 @@ describe('fleet roster parsing', () => {
expect(env).toContain('MOSAIC_TMUX_SOCKET=\n'); expect(env).toContain('MOSAIC_TMUX_SOCKET=\n');
}); });
it('preserves home-relative traversal until the shared environment boundary rejects it', async () => {
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
defaults: { working_directory: workingDirectory },
agents: [{ name: 'coder0', runtime: 'pi' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(() => generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toThrow(
expect.objectContaining({
diagnostic: expect.objectContaining({
code: 'unsafe-path',
key: 'MOSAIC_AGENT_WORKDIR',
}),
}),
);
}
});
it('generates deterministic per-agent EnvironmentFile content', async () => { it('generates deterministic per-agent EnvironmentFile content', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json'); const rosterPath = join(cleanup, 'roster.json');

View File

@@ -561,7 +561,7 @@ function generateAgentEnvValues(
MOSAIC_AGENT_MODEL: agent.modelHint ?? '', MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '', MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '', MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory), MOSAIC_AGENT_WORKDIR: workingDirectory,
MOSAIC_TMUX_SOCKET: roster.tmux.socketName, MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
}; };
} }
@@ -2742,10 +2742,6 @@ function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | str
return value; return value;
} }
function expandHome(path: string): string {
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
}
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> { async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
const failures: string[] = []; const failures: string[] = [];
for (const agentName of agentNames) { for (const agentName of agentNames) {

View File

@@ -274,17 +274,64 @@ describe('fleet roster-owned reconciler', (): void => {
]); ]);
}); });
it.each(['start', 'apply'] as const)( it.each(['plan', 'status', 'doctor', 'verify'] as const)(
'fails closed before %s can start fixed named-socket services for a default-server roster', 'keeps default-server %s observational and free of lifecycle effects',
async (command) => { async (command) => {
const calls: string[][] = []; const calls: string[][] = [];
const defaultServerRoster: FleetRosterV2 = {
...roster,
tmux: { ...roster.tmux, socketName: '' },
};
await expect(
executeFleetReconcile({
roster: defaultServerRoster,
command,
deps: deps({
runner: async (executable, args) => {
calls.push([executable, ...args]);
if (executable === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\n', stderr: '', exitCode: 0 };
}
if (executable === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: 'ActiveState=inactive\n', stderr: '', exitCode: 0 };
},
}),
}),
).resolves.toMatchObject({ applied: false, lifecycle: 'not-applied' });
expect(
calls.some(
([executable, , action]): boolean =>
executable === 'systemctl' &&
(action === 'start' || action === 'stop' || action === 'restart'),
),
).toBe(false);
},
);
it.each(['start', 'stop', 'restart', 'apply', 'reconcile'] as const)(
'fails closed before %s can route fixed named-socket services for a default-server roster',
async (command) => {
const calls: string[][] = [];
let projectionPrepares = 0;
let projectionApplies = 0;
const defaultServerRoster: FleetRosterV2 = { const defaultServerRoster: FleetRosterV2 = {
...roster, ...roster,
tmux: { ...roster.tmux, socketName: '' }, tmux: { ...roster.tmux, socketName: '' },
agents: [ agents: [
{ {
...roster.agents[0]!, ...roster.agents[0]!,
lifecycle: { enabled: true, desiredState: 'running' }, lifecycle: {
enabled: true,
desiredState: command === 'start' || command === 'restart' ? 'running' : 'stopped',
},
}, },
], ],
}; };
@@ -296,28 +343,23 @@ describe('fleet roster-owned reconciler', (): void => {
expectedGeneration: 7, expectedGeneration: 7,
deps: deps({ deps: deps({
readRoster: async () => defaultServerRoster, readRoster: async () => defaultServerRoster,
prepareProjections: async () => {
projectionPrepares += 1;
return [{ agentName: 'coder0' }];
},
applyProjection: async () => {
projectionApplies += 1;
},
runner: async (executable, args) => { runner: async (executable, args) => {
calls.push([executable, ...args]); calls.push([executable, ...args]);
if (executable === 'tmux' && args.includes('list-sessions')) {
return { stdout: '', stderr: '', exitCode: 1 };
}
return { stdout: '', stderr: '', exitCode: 0 }; return { stdout: '', stderr: '', exitCode: 0 };
}, },
}), }),
}), }),
).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' }); ).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' });
expect(calls).not.toContainEqual([ expect(projectionPrepares).toBe(0);
'systemctl', expect(projectionApplies).toBe(0);
'--user', expect(calls).toEqual([]);
'start',
'mosaic-tmux-holder.service',
]);
expect(calls).not.toContainEqual([
'systemctl',
'--user',
'start',
'mosaic-agent@coder0.service',
]);
}, },
); );

View File

@@ -479,18 +479,14 @@ function assertVerificationSafe(plan: FleetReconcilePlan): void {
} }
function assertLifecycleSocketAuthority(request: FleetReconcileRequest): void { function assertLifecycleSocketAuthority(request: FleetReconcileRequest): void {
const mayStart = const usesFixedLifecycleUnits =
request.command === 'start' || request.command === 'apply' ||
request.command === 'restart' || request.command === 'reconcile' ||
((request.command === 'apply' || request.command === 'reconcile') && isLifecycleCommand(request.command);
request.roster.agents.some( if (usesFixedLifecycleUnits && request.roster.tmux.socketName === '') {
(agent: FleetRosterV2Agent): boolean =>
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running',
));
if (mayStart && request.roster.tmux.socketName === '') {
throw new FleetReconcileError( throw new FleetReconcileError(
'lifecycle-precondition-failed', 'lifecycle-precondition-failed',
'Default-server lifecycle start is unsupported by the fixed named-socket systemd units.', 'Default-server lifecycle mutation is unsupported by the fixed named-socket systemd units.',
); );
} }
} }

View File

@@ -9,7 +9,7 @@ import {
symlink, symlink,
writeFile, writeFile,
} from 'node:fs/promises'; } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import { import {
@@ -87,6 +87,42 @@ describe('generated fleet agent environment boundary', (): void => {
}).toThrow(AgentEnvBoundaryError); }).toThrow(AgentEnvBoundaryError);
}); });
it('rejects traversal in home-relative workdirs before expansion', (): void => {
for (const workingDirectory of ['~/../escape', '~/src/../../escape']) {
expect((): void => {
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_AGENT_WORKDIR: workingDirectory,
});
}).toThrow(
expect.objectContaining({
diagnostic: expect.objectContaining({
code: 'unsafe-path',
key: 'MOSAIC_AGENT_WORKDIR',
}),
}),
);
}
});
it('expands home-relative workdirs before preserving absolute-path validation', (): void => {
expect(
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_AGENT_WORKDIR: '~/src',
}),
).toContain(`MOSAIC_AGENT_WORKDIR=${join(homedir(), 'src')}\n`);
for (const workingDirectory of ['relative/path', '../outside']) {
expect((): void => {
renderGeneratedAgentEnvironment({
...generatedValues,
MOSAIC_AGENT_WORKDIR: workingDirectory,
});
}).toThrow(AgentEnvBoundaryError);
}
});
it('previews legacy relocation and quarantine without exposing content or mutating files', async (): Promise<void> => { it('previews legacy relocation and quarantine without exposing content or mutating files', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-')); cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic'); const mosaicHome = join(cleanup, 'mosaic');

View File

@@ -1,5 +1,6 @@
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { compareCodePoints } from './deterministic-order.js'; import { compareCodePoints } from './deterministic-order.js';
@@ -318,7 +319,7 @@ function normalizeGeneratedValues(
for (const key of GENERATED_AGENT_ENV_KEYS) { for (const key of GENERATED_AGENT_ENV_KEYS) {
const value = values[key]; const value = values[key];
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, ''); if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
normalized[key] = value; normalized[key] = key === 'MOSAIC_AGENT_WORKDIR' ? expandHomeDirectory(value) : value;
} }
for (const [key, value] of Object.entries(values)) { for (const [key, value] of Object.entries(values)) {
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value); if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
@@ -327,6 +328,12 @@ function normalizeGeneratedValues(
return Object.freeze(normalized); return Object.freeze(normalized);
} }
function expandHomeDirectory(path: string): string {
if (path === '~') return homedir();
if (!path.startsWith('~/') || path.split('/').includes('..')) return path;
return join(homedir(), path.slice(2));
}
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string { function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
if (Object.keys(values).length === 0) return ''; if (Object.keys(values).length === 0) return '';
const parsed = parseAgentEnvironment( const parsed = parseAgentEnvironment(

View File

@@ -4,6 +4,8 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import YAML from 'yaml'; import YAML from 'yaml';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { loadFleetRoster } from '../commands/fleet.js';
import { executeFleetReconcile } from './fleet-reconciler.js';
import { compareCodePoints } from './deterministic-order.js'; import { compareCodePoints } from './deterministic-order.js';
import { SHIPPED_FLEET_ARTIFACT_DISPOSITIONS } from './example-profile-dispositions.js'; import { SHIPPED_FLEET_ARTIFACT_DISPOSITIONS } from './example-profile-dispositions.js';
import { import {
@@ -11,6 +13,7 @@ import {
previewV1ToV2Migration, previewV1ToV2Migration,
validateShippedFleetMigrationDispositions, validateShippedFleetMigrationDispositions,
type V1ToV2MigrationDecisions, type V1ToV2MigrationDecisions,
type V1ToV2MigrationPreview,
} from './v1-v2-migration.js'; } from './v1-v2-migration.js';
const temporaryDirectories: string[] = []; const temporaryDirectories: string[] = [];
@@ -529,6 +532,122 @@ describe('v1-to-v2 migration preview', (): void => {
}); });
}); });
it('matches production v1 reset defaults when a declared runtime config is empty', async () => {
const source = v1Source.replace(' pi:\n reset_command: /new', ' pi: {}');
const productionSource = source
.replace(/ - name: remote0[\s\S]*?connector:/, 'connector:')
.replace(/connector:[\s\S]*$/, '');
const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-runtime-parity-')), 'roster.yaml');
temporaryDirectories.push(dirname(rosterPath));
await writeFile(rosterPath, productionSource);
const productionRoster = await loadFleetRoster(rosterPath);
const preview = await previewV1ToV2Migration({
source,
decisions: decisions(),
observations: observations(),
personaDirs: await semanticDirs(),
});
expect(preview.status).toBe('ready');
expect(productionRoster.runtimes.pi?.resetCommand).toBe('/clear');
expect(preview.canonicalRoster?.runtimes.pi?.resetCommand).toBe(
productionRoster.runtimes.pi?.resetCommand,
);
});
it('emits byte-identical preview evidence when equivalent local agents are reordered', async () => {
const coder1 = ` - name: coder1
alias: Coder One
provider: openai
runtime: pi
class: reviewer
working_directory: /srv/coder1
reasoning_level: high
tool_policy: reviewer
`;
const sourceA = v1Source.replace(' - name: remote0', `${coder1} - name: remote0`);
const sourceB = sourceA.replace(
/( - name: coder0[\s\S]*?)( - name: coder1[\s\S]*?)( - name: remote0)/,
'$2$1$3',
);
const migrationDecisions = decisions();
migrationDecisions.agents.coder1 = {
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
enabled: true,
launchYolo: false,
};
const lifecycle = {
...observations(),
coder1: { systemd: 'active' as const, tmux: 'present' as const },
};
const dirs = await semanticDirs();
const previews = await Promise.all(
[sourceA, sourceB].map((source) =>
previewV1ToV2Migration({
source,
decisions: migrationDecisions,
observations: lifecycle,
personaDirs: dirs,
}),
),
);
const previewA = previews[0]!;
const previewB = previews[1]!;
expect(previewA.status).toBe('ready');
expect(previewB.status).toBe('ready');
expect(previewB.evidence.observations).toEqual(previewA.evidence.observations);
expect({
...previewB.evidence,
source: { ...previewB.evidence.source, sha256: '<source-specific>' },
}).toEqual({
...previewA.evidence,
source: { ...previewA.evidence.source, sha256: '<source-specific>' },
});
expect(previewB.canonicalYaml).toBe(previewA.canonicalYaml);
});
it('emits byte-identical preview evidence when equivalent remote agents are reordered', async () => {
const remote1 = ` - name: remote1
runtime: pi
class: reviewer
host: review.example.test
ssh: operator@review.example.test
socket: review-fleet
`;
const sourceA = v1Source.replace('connector:', `${remote1}connector:`);
const sourceB = sourceA.replace(
/( - name: remote0[\s\S]*?)( - name: remote1[\s\S]*?)(connector:)/,
'$2$1$3',
);
const dirs = await semanticDirs();
const previews = await Promise.all(
[sourceA, sourceB].map((source) =>
previewV1ToV2Migration({
source,
decisions: decisions(),
observations: observations(),
personaDirs: dirs,
}),
),
);
const previewA = previews[0]!;
const previewB = previews[1]!;
const semanticOutput = (preview: V1ToV2MigrationPreview): object => ({
...preview,
evidence: {
...preview.evidence,
source: { ...preview.evidence.source, sha256: '<source-specific>' },
},
});
expect(previewA.status).toBe('ready');
expect(previewB.status).toBe('ready');
expect(semanticOutput(previewB)).toEqual(semanticOutput(previewA));
});
it('emits byte-identical canonical evidence regardless of locale collation', async () => { it('emits byte-identical canonical evidence regardless of locale collation', async () => {
expect(['😀', 'z', '', 'ä'].sort(compareCodePoints)).toEqual(['z', 'ä', '', '😀']); expect(['😀', 'z', '', 'ä'].sort(compareCodePoints)).toEqual(['z', 'ä', '', '😀']);
const dirs = await semanticDirs(); const dirs = await semanticDirs();
@@ -797,6 +916,48 @@ describe('v1-to-v2 migration preview', (): void => {
).toContain(forbiddenValue); ).toContain(forbiddenValue);
}); });
it('feeds a ready home-relative candidate through the production projection boundary', async () => {
const source = v1Source.replace('working_directory: /srv/coder0', 'working_directory: ~/src');
const preview = await previewV1ToV2Migration({
source,
decisions: decisions(),
observations: observations(),
personaDirs: await semanticDirs(),
});
expect(preview.status).toBe('ready');
expect(preview.canonicalRoster?.agents[0]?.workingDirectory).toBe('~/src');
expect(preview.canonicalYaml).toContain('working_directory: ~/src');
const canonicalRoster = preview.canonicalRoster!;
const mosaicHome = await mkdtemp(join(tmpdir(), 'v1-v2-production-projection-'));
temporaryDirectories.push(mosaicHome);
const fleetDir = join(mosaicHome, 'fleet');
const agentEnvDir = join(fleetDir, 'agents');
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
await chmod(mosaicHome, 0o700);
await chmod(fleetDir, 0o700);
await chmod(agentEnvDir, 0o700);
const projectionDirs = await semanticDirs();
await expect(
executeFleetReconcile({
roster: canonicalRoster,
command: 'plan',
deps: {
mosaicHome,
rolesDir: projectionDirs.rolesDir,
overrideDir: projectionDirs.overrideDir,
runner: async (command, args) => {
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '', stderr: '', exitCode: 1 };
}
return { stdout: 'ActiveState=inactive\n', stderr: '', exitCode: 0 };
},
},
}),
).resolves.toMatchObject({ applied: false, projections: 'not-applied' });
});
it('blocks undeclared v1 fields, synonym collisions, and duplicate names', async () => { it('blocks undeclared v1 fields, synonym collisions, and duplicate names', async () => {
const baseOptions = { const baseOptions = {
decisions: decisions(), decisions: decisions(),

View File

@@ -1,6 +1,5 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import YAML from 'yaml'; import YAML from 'yaml';
import { canonicalizeRoleClass, type PersonaDirs } from '../commands/fleet-personas.js'; import { canonicalizeRoleClass, type PersonaDirs } from '../commands/fleet-personas.js';
@@ -646,11 +645,14 @@ export async function previewV1ToV2Migration(
} }
} }
const canonicalInventoryOnlyAgents = [...inventoryOnlyAgents].sort((left, right): number =>
compareCodePoints(left.name, right.name),
);
const status = blockers.length === 0 ? 'ready' : 'blocked'; const status = blockers.length === 0 ? 'ready' : 'blocked';
return { return {
status, status,
inventory, inventory,
inventoryOnlyAgents, inventoryOnlyAgents: canonicalInventoryOnlyAgents,
blockers, blockers,
...(status === 'ready' && canonicalRoster && canonicalYaml ...(status === 'ready' && canonicalRoster && canonicalYaml
? { canonicalRoster, canonicalYaml } ? { canonicalRoster, canonicalYaml }
@@ -664,10 +666,12 @@ export async function previewV1ToV2Migration(
...(status === 'ready' && canonicalYaml ...(status === 'ready' && canonicalYaml
? { candidate: { sha256: sha256(canonicalYaml), generation: decisions.generation } } ? { candidate: { sha256: sha256(canonicalYaml), generation: decisions.generation } }
: {}), : {}),
observations, observations: [...observations].sort((left, right): number =>
compareCodePoints(left.agent, right.agent),
),
excludedInventory: [ excludedInventory: [
...(raw.connector === undefined ? [] : ['connector']), ...(raw.connector === undefined ? [] : ['connector']),
...inventoryOnlyAgents.map(({ name }): string => `agents.${name}`), ...canonicalInventoryOnlyAgents.map(({ name }): string => `agents.${name}`),
], ],
recovery: { recovery: {
executable: false, executable: false,
@@ -1161,9 +1165,7 @@ function normalizeRuntimes(
} }
const runtime = record(value); const runtime = record(value);
const resetCommand = const resetCommand =
stringValue(runtime.reset_command) || stringValue(runtime.reset_command) || stringValue(runtime.resetCommand) || '/clear';
stringValue(runtime.resetCommand) ||
V1_RUNTIME_RESETS[name];
result[name] = { reset_command: resetCommand }; result[name] = { reset_command: resetCommand };
} }
return result; return result;
@@ -1294,15 +1296,11 @@ function generatedValues(
MOSAIC_AGENT_MODEL: agent.model, MOSAIC_AGENT_MODEL: agent.model,
MOSAIC_AGENT_REASONING: agent.reasoning, MOSAIC_AGENT_REASONING: agent.reasoning,
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy, MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
MOSAIC_AGENT_WORKDIR: expandHome(agent.workingDirectory), MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
MOSAIC_TMUX_SOCKET: roster.tmux.socketName, MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
}; };
} }
function expandHome(path: string): string {
return path === '~' ? homedir() : path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
}
function safeEnvironmentDiagnostic(error: unknown): string { function safeEnvironmentDiagnostic(error: unknown): string {
if ( if (
typeof error === 'object' && typeof error === 'object' &&