Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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' &&
|
||||||
|
|||||||
Reference in New Issue
Block a user