From 04891422b3d4528d567074a3f8d4db85fa5e12a1 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 16 Jul 2026 00:57:33 -0500 Subject: [PATCH] feat(fleet): harden v1 migration preview Co-Authored-By: Claude Opus 4.8 --- .../migration/example-profile-disposition.md | 3 +- docs/fleet/migration/v1-to-v2.md | 43 ++--- .../758-fcm-m4-001-v1-v2-migrator.md | 13 +- packages/mosaic/src/cli-smoke.spec.ts | 38 +++++ .../commands/fleet-migration-command.spec.ts | 49 ++++++ .../src/commands/fleet-migration-command.ts | 37 ++-- packages/mosaic/src/commands/fleet.spec.ts | 46 +++++ packages/mosaic/src/commands/fleet.ts | 6 +- .../mosaic/src/fleet/fleet-reconciler.spec.ts | 78 +++++++-- packages/mosaic/src/fleet/fleet-reconciler.ts | 16 +- .../src/fleet/generated-env-boundary.spec.ts | 38 ++++- .../src/fleet/generated-env-boundary.ts | 9 +- .../mosaic/src/fleet/v1-v2-migration.spec.ts | 161 ++++++++++++++++++ packages/mosaic/src/fleet/v1-v2-migration.ts | 22 ++- 14 files changed, 477 insertions(+), 82 deletions(-) diff --git a/docs/fleet/migration/example-profile-disposition.md b/docs/fleet/migration/example-profile-disposition.md index cc8ab65..385f70f 100644 --- a/docs/fleet/migration/example-profile-disposition.md +++ b/docs/fleet/migration/example-profile-disposition.md @@ -60,7 +60,8 @@ rollback; those gates belong to FCM-M4-002. See [v1-to-v2 preview](./v1-to-v2.md ## Running the guard ```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 diff --git a/docs/fleet/migration/v1-to-v2.md b/docs/fleet/migration/v1-to-v2.md index 7c9770b..18620a7 100644 --- a/docs/fleet/migration/v1-to-v2.md +++ b/docs/fleet/migration/v1-to-v2.md @@ -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 -`--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 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, @@ -41,22 +43,22 @@ be marked disabled. Observed-stopped agents always remain stopped. ## Field 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 | -| 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 | -| `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 | -| `kickstart_template` | No v2 field; explicit inventory-only disposition required | -| agent `host`, `ssh` | `host != fleetHost` is demonstrably remote and inventory-only; `host == fleetHost` stays local; ssh-only, missing fleet-host evidence, or contradictory targets block | -| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition | -| root `connector` | Inventory-only; never contacted or reconciled | -| unknown fields or snake/camel synonym collisions | Inventoried and block readiness | -| `.env.generated` | Rebuild from canonical roster data | -| no legacy `.env` | `absent`; no legacy action required | -| legacy `.env` containing generated keys only | `regenerate-only`; replace later from canonical roster data | -| legacy `.env` containing strict local keys | `relocate-local`; preserve those keys in `.env.local` during a later reviewed cutover | -| legacy `.env` containing forbidden/unsafe/sensitive/malformed keys | `quarantine`; private input only, with diagnostics limited to code, key, and SHA-256 | +| v1 field | v2 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. 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 | +| `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 | +| agent `host`, `ssh` | `host != fleetHost` is demonstrably remote and inventory-only; `host == fleetHost` stays local; ssh-only, missing fleet-host evidence, or contradictory targets block | +| agent `socket` | Same-host candidate only when it matches the canonical fleet socket; conflicts block for explicit future disposition | +| root `connector` | Inventory-only; never contacted or reconciled | +| unknown fields or snake/camel synonym collisions | Inventoried and block readiness | +| `.env.generated` | Rebuild from canonical roster data | +| no legacy `.env` | `absent`; no legacy action required | +| legacy `.env` containing generated keys only | `regenerate-only`; replace later from canonical roster data | +| legacy `.env` containing strict local keys | `relocate-local`; preserve those keys in `.env.local` during a later reviewed cutover | +| legacy `.env` containing forbidden/unsafe/sensitive/malformed keys | `quarantine`; private input only, with diagnostics limited to code, key, and SHA-256 | The only automatic aliases are `implementer โ†’ code`, `reviewer โ†’ review`, and `operator-interaction โ†’ interaction`. Similar or domain-specific names are never inferred. Automatic @@ -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 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 -and does not depend on process locale. Recovery is marked non-executable and assigns the executable gate to -FCM-M4-002. +evidence used for each local candidate. Canonical lifecycle and remote-exclusion evidence ordering compares +Unicode code points directly and does not depend on source-agent order or process locale. Source field +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: diff --git a/docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md b/docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md index 0dab162..0ae0c3a 100644 --- a/docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md +++ b/docs/scratchpads/758-fcm-m4-001-v1-v2-migrator.md @@ -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 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 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 -- Focused migration/compiler/environment/reconciler/CLI: 6 files, 138 tests passed. -- Full `@mosaicstack/mosaic`: 59 files, 883 tests passed. +- Focused migration/compiler/environment/reconciler/CLI: 8 files, 372 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. - Root typecheck: 42 tasks passed. - Root lint: 23 tasks 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. -- Independent review: previous findings were remediated; per coordinator direction, stop before launching review of the next frozen tree. +- 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 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 diff --git a/packages/mosaic/src/cli-smoke.spec.ts b/packages/mosaic/src/cli-smoke.spec.ts index fc40154..e6a7d44 100644 --- a/packages/mosaic/src/cli-smoke.spec.ts +++ b/packages/mosaic/src/cli-smoke.spec.ts @@ -1,3 +1,5 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { Command } from 'commander'; import { registerBrainCommand } from '@mosaicstack/brain'; @@ -16,6 +18,8 @@ import { registerConfigCommand } from './commands/config.js'; // without throwing. This is the "mosaic --help exits 0" gate that // 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]> = [ ['auth', registerAuthCommand], ['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', () => { const program = new Command(); for (const [, register] of REGISTRARS) register(program); diff --git a/packages/mosaic/src/commands/fleet-migration-command.spec.ts b/packages/mosaic/src/commands/fleet-migration-command.spec.ts index d9e3b2b..90ea583 100644 --- a/packages/mosaic/src/commands/fleet-migration-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-migration-command.spec.ts @@ -116,6 +116,55 @@ describe('fleet migrate-v1 preview command', (): void => { 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 ', '', '/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)( 'emits one stable blocked JSON result when --%s is present-empty', async (emptyOption) => { diff --git a/packages/mosaic/src/commands/fleet-migration-command.ts b/packages/mosaic/src/commands/fleet-migration-command.ts index 2235394..0c85a57 100644 --- a/packages/mosaic/src/commands/fleet-migration-command.ts +++ b/packages/mosaic/src/commands/fleet-migration-command.ts @@ -17,9 +17,9 @@ export interface FleetMigrationCommandDeps { } interface PreviewOptions { - readonly source?: string; - readonly decisions?: string; - readonly observations?: string; + readonly source?: string | boolean; + readonly decisions?: string | boolean; + readonly observations?: string | boolean; } /** 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') .command('preview') .description('Compile migration evidence without writing files or changing runtimes') - .option('--source ', 'v1 roster YAML or JSON') - .option('--decisions ', 'explicit migration decisions JSON') - .option('--observations ', 'reviewed lifecycle observations JSON') + .option('--source [path]', 'v1 roster YAML or JSON') + .option('--decisions [path]', 'explicit migration decisions JSON') + .option('--observations [path]', 'reviewed lifecycle observations JSON') .action(async (options: PreviewOptions): Promise => { const readText = deps.readText ?? ((path: string): Promise => readFile(path, 'utf8')); const printJson = @@ -58,7 +58,24 @@ export function registerFleetMigrationCommand( setExitCode(1); 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) { printJson({ status: 'blocked', @@ -77,9 +94,9 @@ export function registerFleetMigrationCommand( const decisionsPath = options.decisions; const observationsPath = options.observations; if ( - sourcePath === undefined || - decisionsPath === undefined || - observationsPath === undefined + typeof sourcePath !== 'string' || + typeof decisionsPath !== 'string' || + typeof observationsPath !== 'string' ) { throw new Error('Validated migration preview options became unavailable.'); } diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index cf7252c..f583470 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -198,6 +198,26 @@ describe('fleet roster parsing', () => { 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 () => { cleanup = await tempDir(); const rosterPath = join(cleanup, 'roster.yaml'); @@ -271,6 +291,32 @@ describe('fleet roster parsing', () => { 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 () => { cleanup = await tempDir(); const rosterPath = join(cleanup, 'roster.json'); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index 8a9d2ed..217584d 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -561,7 +561,7 @@ function generateAgentEnvValues( MOSAIC_AGENT_MODEL: agent.modelHint ?? '', MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '', MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '', - MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory), + MOSAIC_AGENT_WORKDIR: workingDirectory, MOSAIC_TMUX_SOCKET: roster.tmux.socketName, }; } @@ -2742,10 +2742,6 @@ function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | str 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 { const failures: string[] = []; for (const agentName of agentNames) { diff --git a/packages/mosaic/src/fleet/fleet-reconciler.spec.ts b/packages/mosaic/src/fleet/fleet-reconciler.spec.ts index aed8d41..5c81010 100644 --- a/packages/mosaic/src/fleet/fleet-reconciler.spec.ts +++ b/packages/mosaic/src/fleet/fleet-reconciler.spec.ts @@ -274,17 +274,64 @@ describe('fleet roster-owned reconciler', (): void => { ]); }); - it.each(['start', 'apply'] as const)( - 'fails closed before %s can start fixed named-socket services for a default-server roster', + it.each(['plan', 'status', 'doctor', 'verify'] as const)( + 'keeps default-server %s observational and free of lifecycle effects', async (command) => { 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 = { ...roster, tmux: { ...roster.tmux, socketName: '' }, agents: [ { ...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, deps: deps({ readRoster: async () => defaultServerRoster, + prepareProjections: async () => { + projectionPrepares += 1; + return [{ agentName: 'coder0' }]; + }, + applyProjection: async () => { + projectionApplies += 1; + }, runner: async (executable, args) => { calls.push([executable, ...args]); - if (executable === 'tmux' && args.includes('list-sessions')) { - return { stdout: '', stderr: '', exitCode: 1 }; - } return { stdout: '', stderr: '', exitCode: 0 }; }, }), }), ).rejects.toMatchObject({ code: 'lifecycle-precondition-failed' }); - expect(calls).not.toContainEqual([ - 'systemctl', - '--user', - 'start', - 'mosaic-tmux-holder.service', - ]); - expect(calls).not.toContainEqual([ - 'systemctl', - '--user', - 'start', - 'mosaic-agent@coder0.service', - ]); + expect(projectionPrepares).toBe(0); + expect(projectionApplies).toBe(0); + expect(calls).toEqual([]); }, ); diff --git a/packages/mosaic/src/fleet/fleet-reconciler.ts b/packages/mosaic/src/fleet/fleet-reconciler.ts index 5123e35..a0cb4a5 100644 --- a/packages/mosaic/src/fleet/fleet-reconciler.ts +++ b/packages/mosaic/src/fleet/fleet-reconciler.ts @@ -479,18 +479,14 @@ function assertVerificationSafe(plan: FleetReconcilePlan): void { } function assertLifecycleSocketAuthority(request: FleetReconcileRequest): void { - const mayStart = - request.command === 'start' || - request.command === 'restart' || - ((request.command === 'apply' || request.command === 'reconcile') && - request.roster.agents.some( - (agent: FleetRosterV2Agent): boolean => - agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running', - )); - if (mayStart && request.roster.tmux.socketName === '') { + const usesFixedLifecycleUnits = + request.command === 'apply' || + request.command === 'reconcile' || + isLifecycleCommand(request.command); + if (usesFixedLifecycleUnits && request.roster.tmux.socketName === '') { throw new FleetReconcileError( '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.', ); } } diff --git a/packages/mosaic/src/fleet/generated-env-boundary.spec.ts b/packages/mosaic/src/fleet/generated-env-boundary.spec.ts index 9326324..c0c8a96 100644 --- a/packages/mosaic/src/fleet/generated-env-boundary.spec.ts +++ b/packages/mosaic/src/fleet/generated-env-boundary.spec.ts @@ -9,7 +9,7 @@ import { symlink, writeFile, } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { @@ -87,6 +87,42 @@ describe('generated fleet agent environment boundary', (): void => { }).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 => { cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-')); const mosaicHome = join(cleanup, 'mosaic'); diff --git a/packages/mosaic/src/fleet/generated-env-boundary.ts b/packages/mosaic/src/fleet/generated-env-boundary.ts index 1ec15f6..3bd70fa 100644 --- a/packages/mosaic/src/fleet/generated-env-boundary.ts +++ b/packages/mosaic/src/fleet/generated-env-boundary.ts @@ -1,5 +1,6 @@ import { createHash, randomUUID } from 'node:crypto'; 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 { compareCodePoints } from './deterministic-order.js'; @@ -318,7 +319,7 @@ function normalizeGeneratedValues( for (const key of GENERATED_AGENT_ENV_KEYS) { const value = values[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)) { if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value); @@ -327,6 +328,12 @@ function normalizeGeneratedValues( 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>): string { if (Object.keys(values).length === 0) return ''; const parsed = parseAgentEnvironment( diff --git a/packages/mosaic/src/fleet/v1-v2-migration.spec.ts b/packages/mosaic/src/fleet/v1-v2-migration.spec.ts index 38e20fa..6b14df8 100644 --- a/packages/mosaic/src/fleet/v1-v2-migration.spec.ts +++ b/packages/mosaic/src/fleet/v1-v2-migration.spec.ts @@ -4,6 +4,8 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import YAML from 'yaml'; 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 { SHIPPED_FLEET_ARTIFACT_DISPOSITIONS } from './example-profile-dispositions.js'; import { @@ -11,6 +13,7 @@ import { previewV1ToV2Migration, validateShippedFleetMigrationDispositions, type V1ToV2MigrationDecisions, + type V1ToV2MigrationPreview, } from './v1-v2-migration.js'; 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: '' }, + }).toEqual({ + ...previewA.evidence, + source: { ...previewA.evidence.source, sha256: '' }, + }); + 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: '' }, + }, + }); + + 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 () => { expect(['๐Ÿ˜€', 'z', '๎€€', 'รค'].sort(compareCodePoints)).toEqual(['z', 'รค', '๎€€', '๐Ÿ˜€']); const dirs = await semanticDirs(); @@ -797,6 +916,48 @@ describe('v1-to-v2 migration preview', (): void => { ).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 () => { const baseOptions = { decisions: decisions(), diff --git a/packages/mosaic/src/fleet/v1-v2-migration.ts b/packages/mosaic/src/fleet/v1-v2-migration.ts index fabf259..7ba9fea 100644 --- a/packages/mosaic/src/fleet/v1-v2-migration.ts +++ b/packages/mosaic/src/fleet/v1-v2-migration.ts @@ -1,6 +1,5 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; import { join } from 'node:path'; import YAML from 'yaml'; 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'; return { status, inventory, - inventoryOnlyAgents, + inventoryOnlyAgents: canonicalInventoryOnlyAgents, blockers, ...(status === 'ready' && canonicalRoster && canonicalYaml ? { canonicalRoster, canonicalYaml } @@ -664,10 +666,12 @@ export async function previewV1ToV2Migration( ...(status === 'ready' && canonicalYaml ? { candidate: { sha256: sha256(canonicalYaml), generation: decisions.generation } } : {}), - observations, + observations: [...observations].sort((left, right): number => + compareCodePoints(left.agent, right.agent), + ), excludedInventory: [ ...(raw.connector === undefined ? [] : ['connector']), - ...inventoryOnlyAgents.map(({ name }): string => `agents.${name}`), + ...canonicalInventoryOnlyAgents.map(({ name }): string => `agents.${name}`), ], recovery: { executable: false, @@ -1161,9 +1165,7 @@ function normalizeRuntimes( } const runtime = record(value); const resetCommand = - stringValue(runtime.reset_command) || - stringValue(runtime.resetCommand) || - V1_RUNTIME_RESETS[name]; + stringValue(runtime.reset_command) || stringValue(runtime.resetCommand) || '/clear'; result[name] = { reset_command: resetCommand }; } return result; @@ -1294,15 +1296,11 @@ function generatedValues( MOSAIC_AGENT_MODEL: agent.model, MOSAIC_AGENT_REASONING: agent.reasoning, MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy, - MOSAIC_AGENT_WORKDIR: expandHome(agent.workingDirectory), + MOSAIC_AGENT_WORKDIR: agent.workingDirectory, 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 { if ( typeof error === 'object' &&