This commit is contained in:
@@ -102,7 +102,7 @@ mosaic yolo pi # Launch Pi in yolo mode
|
||||
The launcher:
|
||||
|
||||
1. Verifies `~/.config/mosaic` exists
|
||||
2. Resolves identity: standalone launches auto-run `mosaic init` when `SOUL.md` is missing; exact roster-owned fleet launches atomically seed only missing `SOUL.md`/`USER.md` from generic `defaults/` and never prompt
|
||||
2. Resolves identity: standalone launches auto-run `mosaic init` when `SOUL.md` is missing; exact roster-owned fleet launches validate name/class, atomically seed only missing `SOUL.md`/`USER.md` from generic `defaults/`, securely consume `USER.md`, and never prompt
|
||||
3. Injects `AGENTS.md` into the runtime
|
||||
4. Forwards all arguments to the runtime CLI
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"build": "tsc",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test": "pnpm run test:vitest && pnpm run test:framework-shell",
|
||||
"test:vitest": "pnpm run build && vitest run --passWithNoTests",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { FileConfigAdapter } from '../config/file-adapter.js';
|
||||
import { seedFleetIdentityDefaults } from './fleet-first-start-identity.js';
|
||||
import { composeContract } from './launch.js';
|
||||
|
||||
/**
|
||||
@@ -319,6 +320,7 @@ describe('composeContract — overlay composer', () => {
|
||||
].join('\n'),
|
||||
);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'exact-self';
|
||||
expect(seedFleetIdentityDefaults(installedHome)).toEqual(['SOUL.md', 'USER.md']);
|
||||
|
||||
const composed = composeContract('pi', installedHome);
|
||||
expect(composed).toContain(sourceTools);
|
||||
@@ -332,6 +334,23 @@ describe('composeContract — overlay composer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a USER.md replacement symlink at the point of composition', () => {
|
||||
writeFileSync(join(fixture.home, 'defaults', 'SOUL.md'), '# Generic soul\n');
|
||||
writeFileSync(join(fixture.home, 'defaults', 'USER.md'), '# Generic user\n');
|
||||
expect(seedFleetIdentityDefaults(fixture.home)).toEqual(['SOUL.md']);
|
||||
|
||||
const userPath = join(fixture.home, 'USER.md');
|
||||
const external = join(fixture.root, 'attacker-user.md');
|
||||
writeFileSync(external, 'UNSAFE-REPLACEMENT-USER-CONTENT\n');
|
||||
rmSync(userPath);
|
||||
symlinkSync(external, userPath);
|
||||
|
||||
expect(() => composeContract('pi', fixture.home)).toThrow(
|
||||
`fleet identity installed is unavailable or unsafe: ${userPath}`,
|
||||
);
|
||||
expect(readFileSync(external, 'utf8')).toBe('UNSAFE-REPLACEMENT-USER-CONTENT\n');
|
||||
});
|
||||
|
||||
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||
'never injects installed TOOLS.md through a target symlink for %s',
|
||||
(runtime) => {
|
||||
|
||||
@@ -115,6 +115,17 @@ describe('seedFleetIdentityDefaults', () => {
|
||||
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed when the configured Mosaic home is not a directory', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-identity-invalid-home-'));
|
||||
roots.push(root);
|
||||
const mosaicHome = join(root, 'mosaic-home');
|
||||
writeFixture(mosaicHome, 'not a directory\n');
|
||||
|
||||
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
|
||||
`fleet identity installed is unavailable or unsafe: ${join(mosaicHome, 'SOUL.md')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a symlinked default instead of following it', () => {
|
||||
const mosaicHome = createMosaicHome();
|
||||
const source = join(mosaicHome, 'defaults', 'SOUL.md');
|
||||
@@ -139,6 +150,24 @@ describe('seedFleetIdentityDefaults', () => {
|
||||
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['SOUL.md', 'USER.md'] as const)(
|
||||
'rejects a dangling %s destination before publishing its counterpart',
|
||||
(entry) => {
|
||||
const mosaicHome = createMosaicHome();
|
||||
const destination = join(mosaicHome, entry);
|
||||
const counterpart = join(mosaicHome, entry === 'SOUL.md' ? 'USER.md' : 'SOUL.md');
|
||||
symlinkSync(join(mosaicHome, 'missing-identity-target'), destination);
|
||||
|
||||
expect(existsSync(destination)).toBe(false);
|
||||
expect(lstatSync(destination).isSymbolicLink()).toBe(true);
|
||||
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
|
||||
`fleet identity installed is unavailable or unsafe: ${destination}`,
|
||||
);
|
||||
expect(lstatSync(destination).isSymbolicLink()).toBe(true);
|
||||
expect(existsSync(counterpart)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an oversized source before publishing a partial identity', () => {
|
||||
const mosaicHome = createMosaicHome();
|
||||
const source = join(mosaicHome, 'defaults', 'USER.md');
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { existsSync, linkSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { linkSync, lstatSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
|
||||
const MAX_IDENTITY_CONTRACT_BYTES = 256 * 1024;
|
||||
export const FLEET_IDENTITY_DEFAULTS = ['SOUL.md', 'USER.md'] as const;
|
||||
|
||||
function isAlreadyExistsError(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'EEXIST';
|
||||
function isFilesystemError(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === code;
|
||||
}
|
||||
|
||||
/** @internal Publish a complete temporary file without replacing any path. */
|
||||
@@ -16,11 +16,16 @@ export function linkIdentityContractNoClobber(source: string, destination: strin
|
||||
linkSync(source, destination);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if (isAlreadyExistsError(error)) return false;
|
||||
if (isFilesystemError(error, 'EEXIST')) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function unsafeIdentityError(kind: 'default' | 'installed', path: string, error: unknown): Error {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return new Error(`fleet identity ${kind} is unavailable or unsafe: ${path} (${reason})`);
|
||||
}
|
||||
|
||||
function readIdentityContract(
|
||||
mosaicHome: string,
|
||||
path: string,
|
||||
@@ -32,8 +37,39 @@ function readIdentityContract(
|
||||
maxBytes: MAX_IDENTITY_CONTRACT_BYTES,
|
||||
}).content;
|
||||
} catch (error: unknown) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`fleet identity ${kind} is unavailable or unsafe: ${path} (${reason})`);
|
||||
throw unsafeIdentityError(kind, path, error);
|
||||
}
|
||||
}
|
||||
|
||||
function installedEntryExists(path: string): boolean {
|
||||
try {
|
||||
lstatSync(path);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if (isFilesystemError(error, 'ENOENT')) return false;
|
||||
throw unsafeIdentityError('installed', path, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Secure point-of-use read for a top-level identity contract. */
|
||||
export function readOptionalInstalledIdentityContract(
|
||||
mosaicHome: string,
|
||||
entry: (typeof FLEET_IDENTITY_DEFAULTS)[number],
|
||||
required: boolean = false,
|
||||
): Buffer | undefined {
|
||||
const configuredPath = join(mosaicHome, entry);
|
||||
try {
|
||||
// Preserve the launcher's established support for a symlinked Mosaic home,
|
||||
// while pinning this read to the resolved directory. O_NOFOLLOW still
|
||||
// rejects replacement of the identity file itself (or any child ancestor).
|
||||
const canonicalHome = realpathSync(mosaicHome);
|
||||
return readRegularFileSecure(join(canonicalHome, entry), {
|
||||
root: canonicalHome,
|
||||
maxBytes: MAX_IDENTITY_CONTRACT_BYTES,
|
||||
}).content;
|
||||
} catch (error: unknown) {
|
||||
if (!required && isFilesystemError(error, 'ENOENT')) return undefined;
|
||||
throw unsafeIdentityError('installed', configuredPath, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +86,7 @@ export function seedFleetIdentityDefaults(mosaicHome: string): string[] {
|
||||
|
||||
for (const entry of FLEET_IDENTITY_DEFAULTS) {
|
||||
const destination = join(mosaicHome, entry);
|
||||
if (existsSync(destination)) {
|
||||
if (installedEntryExists(destination)) {
|
||||
readIdentityContract(mosaicHome, destination, 'installed');
|
||||
continue;
|
||||
}
|
||||
@@ -69,7 +105,11 @@ export function seedFleetIdentityDefaults(mosaicHome: string): string[] {
|
||||
try {
|
||||
writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
|
||||
temporaryCreated = true;
|
||||
if (linkIdentityContractNoClobber(temporary, destination)) seeded.push(entry);
|
||||
if (linkIdentityContractNoClobber(temporary, destination)) {
|
||||
seeded.push(entry);
|
||||
} else {
|
||||
readIdentityContract(mosaicHome, destination, 'installed');
|
||||
}
|
||||
} finally {
|
||||
if (temporaryCreated) rmSync(temporary, { force: true });
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ function launchEnvironment(
|
||||
capturePath: string,
|
||||
fleet: boolean = true,
|
||||
agentName: string = 'unattended-seat',
|
||||
agentClass: string = 'worker',
|
||||
): NodeJS.ProcessEnv {
|
||||
return {
|
||||
HOME: fixture.home,
|
||||
@@ -115,7 +116,7 @@ function launchEnvironment(
|
||||
...(fleet
|
||||
? {
|
||||
MOSAIC_AGENT_NAME: agentName,
|
||||
MOSAIC_AGENT_CLASS: 'worker',
|
||||
MOSAIC_AGENT_CLASS: agentClass,
|
||||
}
|
||||
: {}),
|
||||
MOSAIC_TEST_RUNTIME_CAPTURE: capturePath,
|
||||
@@ -129,6 +130,7 @@ function launchSync(
|
||||
readonly capturePath?: string;
|
||||
readonly fleet?: boolean;
|
||||
readonly agentName?: string;
|
||||
readonly agentClass?: string;
|
||||
} = {},
|
||||
): SpawnSyncReturns<string> {
|
||||
const capturePath = options.capturePath ?? fixture.capturePath;
|
||||
@@ -142,6 +144,7 @@ function launchSync(
|
||||
capturePath,
|
||||
options.fleet ?? true,
|
||||
options.agentName ?? 'unattended-seat',
|
||||
options.agentClass ?? 'worker',
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -300,6 +303,20 @@ describe('fleet unattended first start (#1264)', () => {
|
||||
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a mismatched ambient fleet class before seeding or prompting', () => {
|
||||
const fixture = createGreenfieldFixture();
|
||||
|
||||
const result = launchSync(fixture, { agentClass: 'reviewer' });
|
||||
const output = outputOf(result);
|
||||
|
||||
expect(result.status, output).toBe(1);
|
||||
expect(output).toContain('Refusing split identity authority');
|
||||
expect(output).not.toContain('Running setup wizard');
|
||||
expect(existsSync(fixture.capturePath)).toBe(false);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
|
||||
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([' unattended-seat', 'unattended-seat ', ''])(
|
||||
'refuses non-exact ambient fleet name %j before seeding',
|
||||
(agentName: string) => {
|
||||
|
||||
@@ -30,7 +30,10 @@ import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||
import { seedFleetIdentityDefaults } from './fleet-first-start-identity.js';
|
||||
import {
|
||||
readOptionalInstalledIdentityContract,
|
||||
seedFleetIdentityDefaults,
|
||||
} from './fleet-first-start-identity.js';
|
||||
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
@@ -231,6 +234,18 @@ function checkRuntime(cmd: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function assertAmbientFleetClassMatches(canonicalName: string, canonicalClass: string): void {
|
||||
const configuredClass = process.env['MOSAIC_AGENT_CLASS'];
|
||||
if (!configuredClass?.trim()) return;
|
||||
|
||||
const ambientClass = canonicalizeRoleClass(configuredClass).canonicalClass;
|
||||
if (ambientClass !== canonicalClass) {
|
||||
throw new Error(
|
||||
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalName}" resolves to "${canonicalClass}". Refusing split identity authority.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function checkSoul(): void {
|
||||
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
||||
const fleetAgentName = process.env['MOSAIC_AGENT_NAME'];
|
||||
@@ -247,6 +262,10 @@ function checkSoul(): void {
|
||||
`canonical fleet identity is unavailable: ${fleetIdentity.error ?? 'exact roster member was not resolved'}`,
|
||||
);
|
||||
}
|
||||
assertAmbientFleetClassMatches(
|
||||
fleetIdentity.identity.member.name,
|
||||
fleetIdentity.identity.member.className,
|
||||
);
|
||||
const seeded = seedFleetIdentityDefaults(MOSAIC_HOME);
|
||||
if (seeded.length > 0) {
|
||||
console.log(
|
||||
@@ -258,7 +277,7 @@ function checkSoul(): void {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[mosaic] ERROR: unattended fleet identity initialization failed: ${reason}`);
|
||||
console.error(
|
||||
`[mosaic] Repair the shipped identity defaults under ${join(MOSAIC_HOME, 'defaults')} and retry this exact roster member.`,
|
||||
'[mosaic] Repair the named fleet roster, launch identity, installed contract, or shipped default, then retry.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -565,7 +584,12 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
|
||||
// USER.md (+ USER.local.md operator overlay, appended directly under the
|
||||
// profile its base owns).
|
||||
const user = readOptional(join(mosaicHome, 'USER.md'));
|
||||
const user =
|
||||
readOptionalInstalledIdentityContract(
|
||||
mosaicHome,
|
||||
'USER.md',
|
||||
process.env['MOSAIC_AGENT_NAME'] !== undefined,
|
||||
)?.toString('utf8') ?? '';
|
||||
if (user) parts.push('\n\n# User Profile\n\n' + user);
|
||||
const userLocal = readOptional(join(mosaicHome, 'USER.local.md'));
|
||||
if (userLocal.trim()) {
|
||||
@@ -577,13 +601,8 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
|
||||
}
|
||||
const canonicalMember = fleetIdentity.identity?.member;
|
||||
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
|
||||
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass;
|
||||
if (ambientClass !== canonicalMember.className) {
|
||||
throw new Error(
|
||||
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
|
||||
);
|
||||
}
|
||||
if (canonicalMember) {
|
||||
assertAmbientFleetClassMatches(canonicalMember.name, canonicalMember.className);
|
||||
}
|
||||
|
||||
// TOOLS.md
|
||||
|
||||
Reference in New Issue
Block a user