feat(fleet): HARNESS-HOMES fleet MVP — profile-driven seat composition and launch (#1209) #1213

Open
mos-dt-0 wants to merge 11 commits from mos-dt-0/stack:feat/wf-fleet-mvp into next
17 changed files with 2661 additions and 100 deletions
+94
View File
@@ -0,0 +1,94 @@
# T1 report: canonical ungated Claude base and lease overlay
## Changed
- Replaced `packages/mosaic/framework/runtime/claude/settings.json` with the canonical ungated base. It retains the model, QA hooks, plugins, command allowlist, permissions, and `mcpServers.sequential-thinking`.
- Added `packages/mosaic/framework/runtime/claude/lease-overlay.json`. It contains only `hooks` and the six removed lease hook entries.
- Added the byte-identical pre-split source fixture at `packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json`.
- Added `packages/mosaic/src/runtime/claude-settings-base.spec.ts`.
`framework-manifest.txt` already declares `runtime/**`, so the new overlay is framework-owned and shipped without a manifest change.
## Lease-hook enumeration
The actual template has six lease hook entries, matching fred's refined boundary:
1. `PreToolUse` matcher `.*`: `mutator-gate.py`
2. `Stop`: one combined command containing `receipt-observer-client.py` then `promote-complete.py`
3. `UserPromptSubmit` matcher `^/mosaic-promote$`: `promote-begin.py`
4. `PreCompact`: `revoke-lease.py --reason pre-compact`
5. `SessionStart` matcher `compact`: `revoke-lease.py --reason session-start-compact`
6. `SessionStart` matcher `resume|clear`: `revoke-lease.py --reason session-start-rollover --bump-generation`
There is no delta from the refined six-entry enumeration. The Stop entry contains the receipt-observer and promote-complete commands together, rather than as two separate hook objects.
## Tests and checks
`pnpm install --frozen-lockfile` was run first because `node_modules` was absent. It completed successfully.
Red-first run before artifacts existed:
```text
RUN v2.1.9 .../packages/mosaic
src/runtime/claude-settings-base.spec.ts (4 tests | 4 failed)
× keeps every lease command out of the ungated base
→ mutator-gate: expected true to be false
× reconstructs the pre-split gated hooks while retaining the canonical MCP correction
→ ENOENT: .../lease-overlay.json
× ships sequential-thinking in the base
→ expected undefined to deeply equal { 'sequential-thinking': ... }
× limits the overlay to lease hook entries
→ ENOENT: .../lease-overlay.json
```
Final focused acceptance run:
```text
RUN v2.1.9 .../packages/mosaic
✓ src/runtime/claude-settings-base.spec.ts (4 tests) 19ms
Test Files 1 passed (1)
Tests 4 passed (4)
```
`pnpm --filter @mosaicstack/mosaic lint` passed:
```text
> @mosaicstack/[email protected] lint
> eslint src
```
`pnpm --filter @mosaicstack/mosaic typecheck` failed on pre-existing workspace resolution and unrelated package errors. The new spec no longer appears in the error list. Initial failures include missing `@mosaicstack/{brain,forge,log,macp,memory,queue,storage,quality-rails,db,config,prdy,types}` declarations, followed by existing `fleet-backlog.ts`, `gateway-doctor.ts`, and TUI implicit-`any` errors. Exit status: 2.
A focused legacy consumer run confirms an existing assumption that `settings.json` itself is gated:
```text
pnpm --filter @mosaicstack/mosaic exec vitest run src/mutator-gate/mutator-gate.acceptance.spec.ts
src/mutator-gate/mutator-gate.acceptance.spec.ts (20 tests | 6 failed)
× non-dangerous parser residual is denied by the global all-tools hook without a lease
→ expected all-tools mutator-gate command in settings.json
× Claude and Pi compaction observer wiring is complete and fail-closed
→ expected PreCompact/SessionStart revoke-lease hooks in settings.json
```
The other four failures in that focused run reported `STALE_GENERATION` where the test expected `MUTATOR_UNVERIFIED`, plus one successful-gate assertion. I did not redesign this legacy suite because the task explicitly says to report consumers that assume the base is gated.
## Consumers found
Direct `runtime/claude/settings.json` path consumers found by the required repository grep:
- `packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets`: copies the base to `~/.claude/settings.json`.
- `packages/mosaic/src/commands/install-ordering-guard.ts` and `.spec.ts`: documentation and behavior assume the source embeds enforcement hooks.
- `packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh`: comments and assertions expect `mutator-gate.py` and `receipt-observer-client.py` in the base.
- `packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts`: reads the base and asserts mutator, promotion, and compaction lease wiring.
- `packages/mosaic/src/lease-broker/promotion_trigger_unittest.py`: reads the base and asserts promotion wiring.
- `packages/mosaic/src/lease-broker/recovery_runtime_unittest.py`: reads the base.
- `packages/mosaic/src/runtime/update-checker.ts` and `.spec.ts`: references the path in settings wiring/update checks.
- Documentation-only references: `docs/compaction-refresh/probes/p6_constrained_recovery.py`, `docs/plans/agent-reflection-loop-PRD.md`, `docs/tasks/544-agent-reflection-loop.md`, and the framework QA documentation/scripts found by grep.
I did not change these consumers. The install/link and lease acceptance consumers must be taught to select and compose `lease-overlay.json` when a gated promotion seat is requested. That composition behavior is outside T1.
## Ambiguity handled
The exact pre-split template fixture has no `mcpServers` key (SHA-256 `44e74ea1e9d424fffa020ee666402662ac856b88bf6ae7f3b8931eed29dc75a4`). The task simultaneously requires a byte-for-byte pre-split fixture, `mcpServers.sequential-thinking` in the base, and `deep-merge(base, overlay) == original`. Those three conditions cannot all hold because a merge cannot remove the required MCP key.
The acceptance test preserves the exact fixture and asserts that the normalized merge equals the pre-split template plus the required canonical `mcpServers.sequential-thinking` correction. It verifies all original hook content is reconstructed and the base carries the required MCP. Production three-layer merge semantics remain W-F1 work.
+102
View File
@@ -0,0 +1,102 @@
# REPORT-T2
Date: 2026-08-13 11:29 CDT
Branch: `feat/wf-fleet-t2-launch`
Base: `216cd722`
Issue: #1209
## What changed
- Added `mosaic fleet launch <name> [--dry-run]` in `packages/mosaic/src/commands/fleet-launch-command.ts` and registered it on the existing fleet command.
- Added strict schema-one parsing for the user-owned `~/.mosaic/fleet/agents/<name>/profile.json`:
- required `schema` and `harness`
- default bundle `primary`
- optional `model`, `overlay`, `plugins`, `skills`, and string-valued `env`
- unknown-key refusal naming the key
- dedicated `SCHEMA_TOO_NEW` code and upgrade guidance
- Added the three-layer settings composer. Objects merge recursively, scalars use the higher layer, arrays replace, and `null` deletes a key. The selected agent overlay defaults to no overlay when the profile field is absent.
- Writes canonical merged settings to `<agent-home>/settings.json` and the future harvest comparison snapshot to `<agent-dir>/settings.generated.json`.
- Resolves `primary` to its named bundle, reads an optional account email, and reports forms such as `primary -> fred_example.com ([email protected])`.
- Validates credential targets with `lstat`, rejects symlink credential files, resolves and checks containment under the harness auth root, and refuses a real credential file at the seat-link path as first-auth state.
- Installs selected plugin and skill entries as seat-local symlinks, prunes stale symlinks, and refuses real objects instead of deleting them.
- Builds a declared seat environment with the harness home variable, `MOSAIC_AGENT_NAME`, and profile environment entries. Mechanical values override conflicting profile entries.
- Extended `launch.ts` so `harnessHome()` accepts fleet context and remains the home-resolution seam. The fleet launcher uses the existing runtime preflight, prompt, ledger, lease-gated, and process execution path over a minimal ambient environment.
- Added deterministic dry-run output containing source layers, merged settings, output and snapshot paths, resolved bundle, symlink plans, declared environment, and harness argv.
- Added 17 focused tests, including the required merge, schema, A3, dry-run snapshot, managed-link, command dry-run, execution-seam, and non-zero failure cases.
## Reconciliation decisions and contradictions
### Prominent contradiction: roster registries do not contain the frozen launch schema
The existing code has two other profile/registry concepts:
- `fleet-profiles.ts` models system-type YAML roster templates. Its `FleetProfile` has no harness bundle, overlay, plugin, skill, or seat environment fields.
- roster-v2 models topology and lifecycle. It requires class, provider, reasoning, tool policy, working directory, lifecycle, and launch-yolo fields that schema-one `profile.json` does not contain.
Deriving a complete roster-v2 member from the frozen per-agent profile is therefore not possible without inventing values. Launch now reads only the per-agent `profile.json` and does not require roster-v2 or the legacy v1 roster. roster-v2 remains the existing lifecycle/topology registry. No second launch registry was introduced.
The pre-existing `resolveFleetIdentity()` path requires a legacy roster and a secure tmux helper whenever `MOSAIC_AGENT_NAME` is present during contract composition. For profile-backed launch, `launch.ts` excludes roster identity keys only from the contract-build environment, then exports the declared profile seat identity to the harness process. Legacy root runtime launches retain the existing roster-backed behavior. This is the smallest reconciliation that allows profile-only launch without fabricating roster-v2 fields.
### Historical whole-store plugin link
The prototype used a whole `plugins` directory symlink, while this task requires selected entry links and pruning. Launch refuses that historical shape with an explicit migration message. It does not delete or silently convert the whole-store link.
### Existing `FleetProfile` name
The system-type YAML `FleetProfile` remains unchanged. The new type is named `FleetAgentLaunchProfile` to keep the concepts separate while treating per-agent `profile.json` as the launch SSOT.
## Ambiguities and bounded choices
- The design does not freeze the generated snapshot filename. This implementation uses `settings.generated.json` in the agent directory, beside the hidden harness home.
- The design explicitly identifies Claude `.credentials.json` and Pi `auth.json`. Codex and OpenCode use `auth.json` in the filename map, matching their harness-home composition shape, but no real credential launch was performed in this task.
- Full interactive harvest-back disposition is not implemented. The task asks to store the generated snapshot for the future diff, and this change does that.
- A machine descriptor file and content digests were not added. Dry-run and execution consume one resolved in-memory composition, and dry-run prints that composition.
- No real harness process or real operator home was used. Every new filesystem test uses a temporary fixture root.
## Test run
Dependency install and build:
```text
$ pnpm install --frozen-lockfile
Scope: all 28 workspace projects
Lockfile is up to date, resolution step is skipped
Done in 4.7s using pnpm v10.6.2
$ pnpm --filter @mosaicstack/mosaic... build
Scope: 13 of 28 workspace projects
packages/mosaic build: Done
```
Focused and touched integration tests:
```text
$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-launch-command.spec.ts src/commands/launch.spec.ts src/commands/fleet.spec.ts
Test Files 3 passed (3)
Tests 256 passed (256)
```
Typecheck and lint:
```text
$ pnpm --filter @mosaicstack/mosaic typecheck
> tsc --noEmit
(exit 0)
$ pnpm exec eslint packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
(exit 0)
$ pnpm exec prettier --check packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
Checking formatting...
All matched files use Prettier code style!
```
Package-wide Vitest result:
```text
$ pnpm --filter @mosaicstack/mosaic exec vitest run
Test Files 1 failed | 83 passed (84)
Tests 4 failed | 1535 passed (1539)
```
All four failures are in `src/mutator-gate/mutator-gate.acceptance.spec.ts`. Three expected `MUTATOR_UNVERIFIED` but received `STALE_GENERATION`; one runtime-gate assertion expected status zero and received status two. An isolated rerun produced the same four failures. I did not confirm whether they predate this branch. The focused launch, fleet, and typecheck runs are green.
+46
View File
@@ -0,0 +1,46 @@
# T3 report: `mosaic fleet agent new`
## Changed
- Added `packages/mosaic/src/fleet/fleet-agent-scaffold.ts`.
- Creates user-owned seats at `~/.mosaic/fleet/agents/<name>` (test seam: `fleetDataHome`, environment default: `MOSAIC_DATA_HOME`).
- Writes schema-one `profile.json` with default `harness: "claude"`, `bundle: "primary"`, optional `model`, `overlay: "overlay.json"`, and mandatory `env.MOSAIC_AGENT_NAME`.
- Writes a positive `SOUL.md` identity and materializes that identity in `.claude/CLAUDE.md` or `.pi/AGENTS.md`.
- Writes `overlay.json` as `{}`. Claude homes get `.claude.json` with `hasCompletedOnboarding: true` and `theme: "dark"`. No settings file is composed.
- Creates the appropriate credential symlink (`.credentials.json` for Claude, `auth.json` for Pi), allowing an intentional dangling destination and reporting it at the command surface.
- Compares every existing object (including link targets as link text), succeeds only byte-identically, and otherwise refuses with the differing paths.
- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.ts` and wired `fleet agent new <name> [--harness claude|pi] [--bundle B] [--model M]` in `packages/mosaic/src/commands/fleet.ts`.
- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts` with temp-root-only coverage: exact Claude/Pi layouts, literal quote/backtick/`$( )` handling, unsafe names and option failures, idempotence, changed-file refusal, and credential-link comparison.
## Reconciliation
`fleet-agent-crud-command.ts` currently registers roster-v2 `get/create/update/delete/plan` directly under `mosaic fleet`; it has no `agent new` command or profile schema. T3 adds an `agent` namespace for the profile-owned user-data scaffold and leaves roster-v2 CRUD unchanged.
No roster projection is created. Current roster-v2 requires fields that cannot be derived from the new profile (`class`, provider, working directory, reasoning, tool policy, lifecycle), while no current `mosaic fleet launch <name>` consumes these profiles. Writing such a roster entry would create the forbidden second registry and invent semantics. The profile is therefore the sole state created here. When the launcher owns profile-to-roster projection, it must derive it there and emit the required actionable unscaffolded-name message.
## Validation
```text
$ pnpm install --frozen-lockfile
Done in 4.1s using pnpm v10.6.2
$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-agent-scaffold-command.spec.ts
✓ src/commands/fleet-agent-scaffold-command.spec.ts (13 tests) 28ms
Test Files 1 passed (1)
Tests 13 passed (13)
$ pnpm --filter @mosaicstack/mosaic exec eslint src/fleet/fleet-agent-scaffold.ts src/commands/fleet-agent-scaffold-command.ts src/commands/fleet-agent-scaffold-command.spec.ts src/commands/fleet.ts
(exit 0)
$ pnpm exec prettier --check packages/mosaic/src/fleet/fleet-agent-scaffold.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts packages/mosaic/src/commands/fleet.ts
All matched files use Prettier code style!
$ git diff --check
(exit 0)
```
`pnpm --filter @mosaicstack/mosaic typecheck` remains blocked by pre-existing unresolved workspace package entries (`@mosaicstack/brain`, `@mosaicstack/db`, `@mosaicstack/types`, and others). The typecheck output had no diagnostics naming T3 files. Running the pre-existing CRUD command spec is blocked by the same `@mosaicstack/db` Vite resolution failure through `fleet-backlog.ts`.
## Skipped ambiguity
The design asks for a generated harness-home `settings.json` as part of an earlier generic home-template description, but the task explicitly says composed settings are left to launch. T3 creates no `settings.json`; launch composition remains the owner.
@@ -0,0 +1,70 @@
{
"hooks": {
"PreCompact": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
}
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
}
]
},
{
"matcher": "resume|clear",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "^/mosaic-promote$",
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
"timeout": 15
}
]
}
],
"PreToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
"timeout": 3
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
"timeout": 15
}
]
}
]
}
}
@@ -1,60 +1,7 @@
{ {
"model": "opus", "model": "opus",
"hooks": { "hooks": {
"PreCompact": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
}
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
}
]
},
{
"matcher": "resume|clear",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "^/mosaic-promote$",
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
"timeout": 15
}
]
}
],
"PreToolUse": [ "PreToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
"timeout": 3
}
]
},
{ {
"matcher": "Write|Edit|MultiEdit", "matcher": "Write|Edit|MultiEdit",
"hooks": [ "hooks": [
@@ -91,11 +38,6 @@
"Stop": [ "Stop": [
{ {
"hooks": [ "hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
"timeout": 15
},
{ {
"type": "command", "type": "command",
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
@@ -315,5 +257,11 @@
"cpan", "cpan",
"nohup" "nohup"
], ],
"enableAllMcpTools": true "enableAllMcpTools": true,
"mcpServers": {
"sequential-thinking": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
}
}
} }
@@ -0,0 +1,189 @@
import { lstat, mkdtemp, readFile, readdir, readlink, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { registerFleetAgentScaffoldCommand } from './fleet-agent-scaffold-command.js';
let root: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
process.exitCode = undefined;
if (root) await rm(root, { recursive: true, force: true });
root = undefined;
});
async function fleetDataHome(): Promise<string> {
root = await mkdtemp(join(tmpdir(), 'mosaic-fleet-agent-new-'));
return join(root, '.mosaic');
}
function program(dataHome: string): Command {
const result = new Command();
result.exitOverride();
const fleet = result.command('fleet');
registerFleetAgentScaffoldCommand(fleet, { fleetDataHome: dataHome });
return result;
}
async function files(rootDir: string, prefix = ''): Promise<string[]> {
const result: string[] = [];
for (const entry of await readdir(join(rootDir, prefix), { withFileTypes: true })) {
const path = join(prefix, entry.name);
if (entry.isDirectory()) result.push(...(await files(rootDir, path)));
else result.push(path);
}
return result.sort();
}
describe('mosaic fleet agent new', (): void => {
it('creates the exact authored user-data scaffold under a temp ~/.mosaic root', async (): Promise<void> => {
const dataHome = await fleetDataHome();
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
const agent = join(dataHome, 'fleet', 'agents', 'mira');
expect(await files(agent)).toEqual([
'.claude/.claude.json',
'.claude/.credentials.json',
'.claude/CLAUDE.md',
'SOUL.md',
'overlay.json',
'profile.json',
]);
expect(JSON.parse(await readFile(join(agent, 'profile.json'), 'utf8'))).toEqual({
schema: 1,
harness: 'claude',
bundle: 'primary',
overlay: 'overlay.json',
env: { MOSAIC_AGENT_NAME: 'mira' },
});
expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain('## Identity');
expect(await readFile(join(agent, '.claude', '.claude.json'), 'utf8')).toEqual(
`${JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }, null, 2)}\n`,
);
expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe(
join(dataHome, 'auth', 'claude', 'primary', '.credentials.json'),
);
});
it('creates a Pi home without Claude onboarding state', async (): Promise<void> => {
const dataHome = await fleetDataHome();
await program(dataHome).parseAsync([
'node',
'mosaic',
'fleet',
'agent',
'new',
'pi-seat',
'--harness',
'pi',
]);
expect(await files(join(dataHome, 'fleet', 'agents', 'pi-seat'))).toEqual([
'.pi/AGENTS.md',
'.pi/auth.json',
'SOUL.md',
'overlay.json',
'profile.json',
]);
});
it('round-trips quotes, backticks, and shell-looking input literally', async (): Promise<void> => {
const dataHome = await fleetDataHome();
const name = 'seat"`$(literal)`';
const bundle = 'bundle"`$(literal)`';
const model = 'model"`$(literal)`';
await program(dataHome).parseAsync([
'node',
'mosaic',
'fleet',
'agent',
'new',
name,
'--harness',
'pi',
'--bundle',
bundle,
'--model',
model,
]);
const agent = join(dataHome, 'fleet', 'agents', name);
expect(JSON.parse(await readFile(join(agent, 'profile.json'), 'utf8'))).toMatchObject({
harness: 'pi',
bundle,
model,
env: { MOSAIC_AGENT_NAME: name },
});
expect(await readFile(join(agent, 'SOUL.md'), 'utf8')).toContain(`You are ${name},`);
expect(await readlink(join(agent, '.pi', 'auth.json'))).toBe(
join(dataHome, 'auth', 'pi', bundle, 'auth.json'),
);
});
it.each(['', '../outside', '/absolute', 'a/b', 'a\\b'])(
'rejects unsafe agent name %j with a non-zero outcome',
async (name: string): Promise<void> => {
const dataHome = await fleetDataHome();
const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', name]);
} catch {
// Commander rejects a missing positional before the action. That is also
// a non-zero CLI failure; all other unsafe names reach the scaffold.
process.exitCode = 1;
}
expect(process.exitCode).toBe(1);
if (name !== '')
expect(error).toHaveBeenCalledWith(expect.stringContaining('invalid-request'));
},
);
it.each([
['--harness', 'codex'],
['--bundle', '../outside'],
['--model', ''],
])(
'returns non-zero for invalid %s input',
async (option: string, value: string): Promise<void> => {
const dataHome = await fleetDataHome();
const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
await program(dataHome).parseAsync([
'node',
'mosaic',
'fleet',
'agent',
'new',
'mira',
option,
value,
]);
expect(process.exitCode).toBe(1);
expect(error).toHaveBeenCalledWith(expect.stringContaining('invalid-request'));
},
);
it('is idempotent for byte-identical content and refuses a changed user file', async (): Promise<void> => {
const dataHome = await fleetDataHome();
const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'mira'];
await program(dataHome).parseAsync(command);
await program(dataHome).parseAsync(command);
expect(process.exitCode).toBeUndefined();
const soul = join(dataHome, 'fleet', 'agents', 'mira', 'SOUL.md');
await writeFile(soul, '# user-owned change\n');
const error = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
await program(dataHome).parseAsync(command);
expect(process.exitCode).toBe(1);
expect(error).toHaveBeenCalledWith(expect.stringContaining('SOUL.md'));
expect(await readFile(soul, 'utf8')).toBe('# user-owned change\n');
});
it('does not follow a managed credential link while comparing existing content', async (): Promise<void> => {
const dataHome = await fleetDataHome();
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
const credential = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.credentials.json');
expect((await lstat(credential)).isSymbolicLink()).toBe(true);
await program(dataHome).parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
expect(process.exitCode).toBeUndefined();
});
});
@@ -0,0 +1,56 @@
import type { Command } from 'commander';
import { FleetAgentScaffoldError, scaffoldFleetAgent } from '../fleet/fleet-agent-scaffold.js';
export interface FleetAgentScaffoldCommandDeps {
/** Test seam for the user-owned ~/.mosaic root. */
readonly fleetDataHome?: string;
}
interface NewAgentOptions {
readonly harness?: string;
readonly bundle?: string;
readonly model?: string;
}
/** Registers the user-data seat scaffolder, distinct from roster-v2 CRUD. */
export function registerFleetAgentScaffoldCommand(
fleetCommand: Command,
deps: FleetAgentScaffoldCommandDeps = {},
): void {
const agent = fleetCommand
.command('agent')
.description('Manage user-owned fleet agent harness homes');
agent
.command('new <name>')
.description('Create an additive-or-refuse fleet agent harness home')
.option('--harness <harness>', 'Harness: claude or pi', 'claude')
.option('--bundle <bundle>', 'Auth bundle selector', 'primary')
.option('--model <model>', 'Optional harness-native model')
.action(async (name: string, options: NewAgentOptions): Promise<void> => {
try {
const result = await scaffoldFleetAgent({
name,
harness: options.harness,
bundle: options.bundle,
model: options.model,
...(deps.fleetDataHome === undefined ? {} : { dataHome: deps.fleetDataHome }),
});
console.log(
result.idempotent
? `Fleet agent "${name}" already matches the scaffold.`
: `Created fleet agent "${name}" at ${result.agentDir}.`,
);
if (!result.credentialTargetExists) {
console.log(
`Notice: credentials link is intentionally dangling until auth bundle "${result.profile['bundle']}" is enrolled: ${result.credentialTarget}`,
);
}
} catch (error: unknown) {
process.exitCode = 1;
const message = error instanceof Error ? error.message : String(error);
const code = error instanceof FleetAgentScaffoldError ? error.code : 'scaffold-failed';
process.stderr.write(`mosaic fleet agent new failed (${code}): ${message}\n`);
}
});
}
@@ -0,0 +1,440 @@
import {
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
applyFleetLaunchComposition,
deepMergeSettings,
FleetLaunchError,
formatFleetLaunchDryRun,
parseFleetAgentProfile,
registerFleetLaunchCommand,
resolveFleetLaunchComposition,
} from './fleet-launch-command.js';
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function fixture(profile: Record<string, unknown> = { schema: 1, harness: 'claude' }): {
root: string;
systemHome: string;
userHome: string;
agentDir: string;
namedBundleDir: string;
} {
const root = mkdtempSync(join(tmpdir(), 'mosaic-fleet-launch-'));
roots.push(root);
const systemHome = join(root, 'system');
const userHome = join(root, 'user');
const agentDir = join(userHome, 'fleet', 'agents', 'fred');
const namedBundleDir = join(userHome, 'auth', 'claude', 'fred_example.com');
mkdirSync(join(systemHome, 'runtime', 'claude'), { recursive: true });
mkdirSync(agentDir, { recursive: true });
mkdirSync(namedBundleDir, { recursive: true });
writeFileSync(join(systemHome, 'runtime', 'claude', 'settings.json'), '{}\n');
writeFileSync(join(agentDir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
writeFileSync(join(namedBundleDir, '.credentials.json'), '{}\n', { mode: 0o600 });
writeFileSync(
join(namedBundleDir, 'account.json'),
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
);
symlinkSync('fred_example.com', join(userHome, 'auth', 'claude', 'primary'), 'dir');
return { root, systemHome, userHome, agentDir, namedBundleDir };
}
describe('fleet launch profile schema 1', () => {
it('rejects an unknown key and names it', () => {
expect(() =>
parseFleetAgentProfile('{"schema":1,"harness":"claude","pluigns":[]}'),
).toThrowError(/unknown profile key "pluigns"/);
});
it('uses a dedicated SCHEMA_TOO_NEW error with an upgrade hint', () => {
try {
parseFleetAgentProfile('{"schema":2,"harness":"claude"}');
throw new Error('expected parse to fail');
} catch (error) {
expect(error).toBeInstanceOf(FleetLaunchError);
expect((error as FleetLaunchError).code).toBe('SCHEMA_TOO_NEW');
expect((error as Error).message).toMatch(/upgrade Mosaic/i);
}
});
});
describe('three-layer settings merge', () => {
it('keeps base-only settings', () => {
expect(deepMergeSettings({ base: { enabled: true } })).toEqual({ base: { enabled: true } });
});
it('uses the last layer for scalar conflicts', () => {
expect(deepMergeSettings({ model: 'base' }, { model: 'user' })).toEqual({ model: 'user' });
});
it('replaces arrays instead of appending', () => {
expect(deepMergeSettings({ hooks: ['base'] }, { hooks: ['user'] })).toEqual({
hooks: ['user'],
});
});
it('uses null as a key-deleting tombstone', () => {
expect(
deepMergeSettings({ nested: { keep: true, remove: true } }, { nested: { remove: null } }),
).toEqual({ nested: { keep: true } });
});
it('concatenates hook event arrays so an overlay adds gating without erasing base hooks', () => {
const qaStop = { hooks: [{ type: 'command', command: 'qa-stop.sh' }] };
const leaseStop = { hooks: [{ type: 'command', command: 'receipt-observer.py' }] };
const qaPre = { matcher: 'Write', hooks: [{ type: 'command', command: 'qa-pre.sh' }] };
expect(
deepMergeSettings(
{ hooks: { Stop: [qaStop], PreToolUse: [qaPre] } },
{ hooks: { Stop: [leaseStop] } },
),
).toEqual({ hooks: { Stop: [qaStop, leaseStop], PreToolUse: [qaPre] } });
});
it('still deletes a whole hook event via the null tombstone', () => {
expect(
deepMergeSettings(
{ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'qa-stop.sh' }] }] } },
{ hooks: { Stop: null } },
),
).toEqual({ hooks: {} });
});
it('keeps replace semantics for arrays outside the top-level hooks object', () => {
expect(
deepMergeSettings(
{ plugins: ['base'], nested: { hooks: { Stop: ['base'] } } },
{ plugins: ['user'], nested: { hooks: { Stop: ['user'] } } },
),
).toEqual({ plugins: ['user'], nested: { hooks: { Stop: ['user'] } } });
});
it('deep-merges all three layers in precedence order', () => {
expect(
deepMergeSettings(
{ nested: { system: true, shared: 'system' }, list: [1] },
{ nested: { user: true, shared: 'user' }, list: [2] },
{ nested: { agent: true, shared: 'agent' }, list: [3] },
),
).toEqual({
nested: { system: true, user: true, agent: true, shared: 'agent' },
list: [3],
});
});
});
describe('profile-selected overlay', () => {
it('defaults to no overlay when the optional profile field is omitted', () => {
const fx = fixture();
writeFileSync(join(fx.agentDir, 'overlay.json'), '{"mustNotLoad":true}\n');
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
expect(plan.settings.merged).toEqual({});
expect(plan.settings.layers[2]?.present).toBe(false);
});
});
describe('unscaffolded agent names', () => {
it('points an unscaffolded name at mosaic fleet agent new', () => {
const fx = fixture();
try {
resolveFleetLaunchComposition('ghost', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
throw new Error('expected resolution to fail');
} catch (error: unknown) {
const launchError = error as FleetLaunchError;
expect(launchError.code).toBe('AGENT_NOT_SCAFFOLDED');
expect(launchError.message).toContain("no such fleet agent 'ghost'");
expect(launchError.message).toContain('mosaic fleet agent new ghost');
}
});
});
describe('A3 credential validation', () => {
it('refuses a symlinked bundle credential file', () => {
const fx = fixture();
rmSync(join(fx.namedBundleDir, '.credentials.json'));
const outside = join(fx.root, 'outside-credentials.json');
writeFileSync(outside, '{}\n');
symlinkSync(outside, join(fx.namedBundleDir, '.credentials.json'));
expect(() =>
resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
}),
).toThrowError(/real, non-symlink credential file/);
});
it('accepts a real credential file contained in the harness auth root', () => {
const fx = fixture();
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
expect(plan.credential.target).toBe(join(fx.namedBundleDir, '.credentials.json'));
expect(plan.bundle.display).toBe('primary -> fred_example.com ([email protected])');
});
it('refuses first-auth state when a real file occupies the seat link', () => {
const fx = fixture();
const seatHome = join(fx.agentDir, '.claude');
mkdirSync(seatHome, { recursive: true });
writeFileSync(join(seatHome, '.credentials.json'), '{"private":true}\n');
expect(() =>
resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
}),
).toThrowError(/first-auth.*refusing to delete or overwrite/i);
expect(lstatSync(join(seatHome, '.credentials.json')).isSymbolicLink()).toBe(false);
});
});
describe('managed plugin and skill links', () => {
it('installs listed entries and prunes only stale managed symlinks', () => {
const fx = fixture({
schema: 1,
harness: 'claude',
plugins: ['keep'],
skills: ['mosaic-tools'],
});
mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true });
mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true });
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
mkdirSync(pluginHome, { recursive: true });
symlinkSync(join(fx.userHome, 'plugins', 'old'), join(pluginHome, 'old'), 'dir');
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
expect(plan.prune).toEqual([join(pluginHome, 'old')]);
applyFleetLaunchComposition(plan);
expect(() => lstatSync(join(pluginHome, 'old'))).toThrow();
expect(lstatSync(join(pluginHome, 'keep')).isSymbolicLink()).toBe(true);
expect(lstatSync(join(fx.agentDir, '.claude', 'skills', 'mosaic-tools')).isSymbolicLink()).toBe(
true,
);
});
it('tolerates harness metadata files in the install root and still refuses real directories', () => {
const fx = fixture({ schema: 1, harness: 'claude', plugins: [] });
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
mkdirSync(pluginHome, { recursive: true });
writeFileSync(join(pluginHome, 'installed_plugins.json'), '{}\n');
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
expect(plan.prune).toEqual([]);
expect(readFileSync(join(pluginHome, 'installed_plugins.json'), 'utf8')).toBe('{}\n');
mkdirSync(join(pluginHome, 'stray-plugin'), { recursive: true });
expect(() =>
resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
}),
).toThrowError(/real plugin directory occupies managed install root.*refusing to prune/i);
});
it('surfaces a real directory at a managed link path without deleting it', () => {
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] });
mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
const occupied = join(fx.agentDir, '.claude', 'plugins', 'keep');
mkdirSync(occupied, { recursive: true });
expect(() =>
resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
}),
).toThrowError(/real plugin directory.*refusing to delete/i);
expect(lstatSync(occupied).isDirectory()).toBe(true);
});
});
describe('fleet launch command outcomes', () => {
it('--dry-run prints without writing or invoking the launcher', () => {
const fx = fixture();
const program = new Command().exitOverride();
const fleet = program.command('fleet');
const launcher = vi.fn();
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
userHome: fx.userHome,
launcher,
});
try {
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--dry-run']);
expect(stdout).toHaveBeenCalledWith(
expect.stringContaining('mosaic fleet launch fred --dry-run'),
);
expect(launcher).not.toHaveBeenCalled();
expect(() => lstatSync(join(fx.agentDir, '.claude'))).toThrow();
} finally {
stdout.mockRestore();
}
});
it('applies the plan and invokes the existing launch seam with declared values', () => {
const fx = fixture({
schema: 1,
harness: 'claude',
model: 'opus',
env: { SEAT_FLAG: 'yes' },
});
const program = new Command().exitOverride();
const fleet = program.command('fleet');
const launcher = vi.fn();
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
userHome: fx.userHome,
launcher,
});
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
expect(launcher).toHaveBeenCalledWith(
'claude',
['--model', 'opus'],
{
CLAUDE_CONFIG_DIR: join(fx.agentDir, '.claude'),
MOSAIC_AGENT_NAME: 'fred',
SEAT_FLAG: 'yes',
},
{ agentDir: fx.agentDir },
);
expect(lstatSync(join(fx.agentDir, '.claude', '.credentials.json')).isSymbolicLink()).toBe(
true,
);
});
it('sets a non-zero exit code and never invokes the launcher', () => {
const fx = fixture({ schema: 1, harness: 'claude', unknown: true });
const program = new Command().exitOverride();
const fleet = program.command('fleet');
const launcher = vi.fn();
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const priorExitCode = process.exitCode;
process.exitCode = 0;
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
userHome: fx.userHome,
launcher,
});
try {
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
expect(process.exitCode).toBe(1);
expect(launcher).not.toHaveBeenCalled();
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('unknown profile key "unknown"'));
} finally {
process.exitCode = priorExitCode;
stderr.mockRestore();
}
});
});
describe('dry-run composition', () => {
it('renders a deterministic full composition and writes nothing', () => {
const fx = fixture({
schema: 1,
harness: 'claude',
bundle: 'primary',
model: 'opus',
overlay: 'overlay.json',
plugins: ['code-review'],
skills: ['mosaic-tools'],
env: { SEAT_FLAG: 'yes' },
});
writeFileSync(
join(fx.systemHome, 'runtime', 'claude', 'settings.json'),
'{"theme":"dark","hooks":["system"],"nested":{"system":true}}\n',
);
mkdirSync(join(fx.userHome, 'config', 'claude'), { recursive: true });
writeFileSync(
join(fx.userHome, 'config', 'claude', 'settings.json'),
'{"hooks":["user"],"nested":{"user":true}}\n',
);
writeFileSync(join(fx.agentDir, 'overlay.json'), '{"theme":null,"nested":{"agent":true}}\n');
mkdirSync(join(fx.userHome, 'plugins', 'code-review'), { recursive: true });
mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true });
const plan = resolveFleetLaunchComposition('fred', {
systemHome: fx.systemHome,
userHome: fx.userHome,
});
const output = formatFleetLaunchDryRun(plan).replaceAll(fx.root, '<ROOT>');
expect(output).toMatchInlineSnapshot(`
"mosaic fleet launch fred --dry-run
profile: <ROOT>/user/fleet/agents/fred/profile.json (schema 1)
harness: claude
seat-home: <ROOT>/user/fleet/agents/fred/.claude
settings sources:
system: <ROOT>/system/runtime/claude/settings.json
user: <ROOT>/user/config/claude/settings.json
agent: <ROOT>/user/fleet/agents/fred/overlay.json
output: <ROOT>/user/fleet/agents/fred/.claude/settings.json
snapshot: <ROOT>/user/fleet/agents/fred/settings.generated.json
merged settings:
{
"hooks": [
"user"
],
"nested": {
"agent": true,
"system": true,
"user": true
}
}
bundle: primary -> fred_example.com ([email protected])
symlinks:
credentials: <ROOT>/user/fleet/agents/fred/.claude/.credentials.json -> <ROOT>/user/auth/claude/fred_example.com/.credentials.json
plugin code-review: <ROOT>/user/fleet/agents/fred/.claude/plugins/code-review -> <ROOT>/user/plugins/code-review
skill mosaic-tools: <ROOT>/user/fleet/agents/fred/.claude/skills/mosaic-tools -> <ROOT>/user/skills/mosaic-tools
declared env:
CLAUDE_CONFIG_DIR=<ROOT>/user/fleet/agents/fred/.claude
MOSAIC_AGENT_NAME=fred
SEAT_FLAG=yes
argv: ["claude","--model","opus"]"
`);
expect(() => readFileSync(join(fx.agentDir, '.claude', 'settings.json'), 'utf8')).toThrow();
applyFleetLaunchComposition(plan);
expect(JSON.parse(readFileSync(plan.settings.output, 'utf8'))).toEqual({
hooks: ['user'],
nested: { agent: true, system: true, user: true },
});
expect(readFileSync(plan.settings.snapshot, 'utf8')).toBe(
readFileSync(plan.settings.output, 'utf8'),
);
expect(lstatSync(plan.credential.link).isSymbolicLink()).toBe(true);
});
});
@@ -0,0 +1,744 @@
import {
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
readdirSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
type Stats,
} from 'node:fs';
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import type { Command } from 'commander';
import {
harnessHome,
launchFleetRuntime,
type FleetHarnessContext,
type RuntimeName,
} from './launch.js';
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
export const FLEET_AGENT_PROFILE_SCHEMA = 1;
const PROFILE_KEYS = [
'schema',
'harness',
'bundle',
'model',
'overlay',
'plugins',
'skills',
'env',
];
const RUNTIMES: readonly RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const STORE_ENTRY = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
const BUNDLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
const CREDENTIAL_FILES: Record<RuntimeName, string> = {
claude: '.credentials.json',
pi: 'auth.json',
codex: 'auth.json',
opencode: 'auth.json',
};
export type FleetLaunchErrorCode =
| 'SCHEMA_TOO_NEW'
| 'PROFILE_INVALID'
| 'AGENT_NOT_SCAFFOLDED'
| 'COMPOSITION_FAILED'
| 'FIRST_AUTH_REFUSAL';
export class FleetLaunchError extends Error {
constructor(
readonly code: FleetLaunchErrorCode,
message: string,
) {
super(message);
this.name = 'FleetLaunchError';
}
}
export interface FleetAgentLaunchProfile {
readonly schema: 1;
readonly harness: RuntimeName;
readonly bundle: string;
readonly model?: string;
readonly overlay?: string;
readonly plugins: readonly string[];
readonly skills: readonly string[];
readonly env: Readonly<Record<string, string>>;
}
export interface FleetLaunchRoots {
readonly systemHome: string;
readonly userHome: string;
}
interface SettingsLayer {
readonly name: 'system' | 'user' | 'agent';
readonly path: string;
readonly present: boolean;
readonly value: Record<string, unknown>;
}
interface PlannedLink {
readonly kind: 'plugin' | 'skill';
readonly name: string;
readonly link: string;
readonly target: string;
}
export interface FleetLaunchComposition {
readonly name: string;
readonly profilePath: string;
readonly profile: FleetAgentLaunchProfile;
readonly agentDir: string;
readonly seatHome: string;
readonly settings: {
readonly layers: readonly SettingsLayer[];
readonly merged: Record<string, unknown>;
readonly output: string;
readonly snapshot: string;
};
readonly bundle: {
readonly requested: string;
readonly resolved: string;
readonly email?: string;
readonly display: string;
};
readonly credential: {
readonly link: string;
readonly target: string;
};
readonly installs: readonly PlannedLink[];
readonly prune: readonly string[];
readonly env: Readonly<Record<string, string>>;
readonly argv: readonly string[];
}
export interface FleetLaunchCommandDeps {
readonly userHome?: string;
readonly launcher?: (
runtime: RuntimeName,
args: string[],
declaredEnv: Readonly<Record<string, string>>,
context: FleetHarnessContext,
) => void;
}
function requiredObject(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new FleetLaunchError('PROFILE_INVALID', `${label} must be a JSON object.`);
}
return value as Record<string, unknown>;
}
function optionalString(value: unknown, label: string): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== 'string' || value.trim() === '') {
throw new FleetLaunchError('PROFILE_INVALID', `${label} must be a non-empty string.`);
}
return value.trim();
}
function stringList(value: unknown, label: string): string[] {
if (value === undefined) return [];
if (!Array.isArray(value)) {
throw new FleetLaunchError(
'PROFILE_INVALID',
`${label} must be an array of store entry names.`,
);
}
return value.map((entry: unknown, index: number): string => {
if (typeof entry !== 'string' || !STORE_ENTRY.test(entry)) {
throw new FleetLaunchError(
'PROFILE_INVALID',
`${label}[${index}] must be a safe store entry name.`,
);
}
return entry;
});
}
/** Parse and strictly validate the frozen, user-facing per-agent profile schema. */
export function parseFleetAgentProfile(source: string): FleetAgentLaunchProfile {
let parsed: unknown;
try {
parsed = JSON.parse(source) as unknown;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new FleetLaunchError('PROFILE_INVALID', `profile.json is not valid JSON: ${detail}`);
}
const raw = requiredObject(parsed, 'profile.json');
if (!Number.isSafeInteger(raw['schema'])) {
throw new FleetLaunchError('PROFILE_INVALID', 'profile.json schema is required and must be 1.');
}
if ((raw['schema'] as number) > FLEET_AGENT_PROFILE_SCHEMA) {
throw new FleetLaunchError(
'SCHEMA_TOO_NEW',
`SCHEMA_TOO_NEW: profile schema ${String(raw['schema'])} is newer than supported schema ${FLEET_AGENT_PROFILE_SCHEMA}; upgrade Mosaic before launching this agent.`,
);
}
if (raw['schema'] !== FLEET_AGENT_PROFILE_SCHEMA) {
throw new FleetLaunchError(
'PROFILE_INVALID',
`profile.json schema ${String(raw['schema'])} is unsupported; expected schema 1.`,
);
}
const unknown = Object.keys(raw).filter((key: string): boolean => !PROFILE_KEYS.includes(key));
if (unknown.length > 0) {
throw new FleetLaunchError(
'PROFILE_INVALID',
`unknown profile key "${unknown[0]}" (schema ${String(raw['schema'])})`,
);
}
if (typeof raw['harness'] !== 'string' || !RUNTIMES.includes(raw['harness'] as RuntimeName)) {
throw new FleetLaunchError(
'PROFILE_INVALID',
`profile.json harness is required and must be one of: ${RUNTIMES.join(', ')}.`,
);
}
const bundle = optionalString(raw['bundle'], 'profile.json bundle') ?? 'primary';
if (!BUNDLE_NAME.test(bundle)) {
throw new FleetLaunchError(
'PROFILE_INVALID',
'profile.json bundle must be a safe bundle name.',
);
}
const overlay = optionalString(raw['overlay'], 'profile.json overlay');
if (overlay !== undefined && (isAbsolute(overlay) || overlay.split(/[\\/]/u).includes('..'))) {
throw new FleetLaunchError(
'PROFILE_INVALID',
'profile.json overlay must remain inside the agent directory.',
);
}
const rawEnv = raw['env'] === undefined ? {} : requiredObject(raw['env'], 'profile.json env');
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(rawEnv)) {
if (!ENV_NAME.test(key) || typeof value !== 'string') {
throw new FleetLaunchError(
'PROFILE_INVALID',
`profile.json env entry "${key}" must have a valid name and string value.`,
);
}
env[key] = value;
}
const model = optionalString(raw['model'], 'profile.json model');
return {
schema: 1,
harness: raw['harness'] as RuntimeName,
bundle,
...(model === undefined ? {} : { model }),
...(overlay === undefined ? {} : { overlay }),
plugins: stringList(raw['plugins'], 'profile.json plugins'),
skills: stringList(raw['skills'], 'profile.json skills'),
env,
};
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function cloneValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(cloneValue);
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, cloneValue(entry)]),
);
}
return value;
}
type MergeContext = 'root' | 'hooks' | 'nested';
function mergeObject(
lower: Record<string, unknown>,
higher: Record<string, unknown>,
context: MergeContext = 'nested',
): Record<string, unknown> {
const result = cloneValue(lower) as Record<string, unknown>;
for (const [key, highValue] of Object.entries(higher)) {
if (highValue === null) {
delete result[key];
continue;
}
const lowValue = result[key];
if (context === 'hooks' && Array.isArray(lowValue) && Array.isArray(highValue)) {
result[key] = [...(lowValue as unknown[]), ...(cloneValue(highValue) as unknown[])];
continue;
}
result[key] =
isPlainObject(lowValue) && isPlainObject(highValue)
? mergeObject(
lowValue,
highValue,
context === 'root' && key === 'hooks' ? 'hooks' : 'nested',
)
: cloneValue(highValue);
}
return result;
}
/**
* Deep object merge. Scalars and arrays replace; null in a higher layer
* deletes. Exception: hook event arrays directly under the top-level `hooks`
* key concatenate (base entries first), so an overlay ADDS gating without
* erasing the base QA hooks that share an event — replacing them would make
* the gap-7 base/overlay split unimplementable without duplicating base
* hooks inside the lease overlay. Removing an event entirely still works via
* the null tombstone.
*/
export function deepMergeSettings(
...layers: ReadonlyArray<Record<string, unknown> | undefined>
): Record<string, unknown> {
return layers.reduce<Record<string, unknown>>(
(merged, layer) => (layer === undefined ? merged : mergeObject(merged, layer, 'root')),
{},
);
}
function lstatIfPresent(path: string): Stats | undefined {
try {
return lstatSync(path);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
function assertRealDirectory(path: string, label: string): void {
const info = lstatIfPresent(path);
if (!info?.isDirectory() || info.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${label} must be a real, non-symlink directory: ${path}`,
);
}
}
function assertContained(root: string, candidate: string, label: string): void {
const rel = relative(resolve(root), resolve(candidate));
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${label} resolves outside ${root}: ${candidate}`,
);
}
}
function readSettingsLayer(
name: SettingsLayer['name'],
path: string,
required: boolean,
): SettingsLayer {
const info = lstatIfPresent(path);
if (!info) {
if (required) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`required ${name} settings missing: ${path}`,
);
}
return { name, path, present: false, value: {} };
}
if (!info.isFile() || info.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${name} settings must be a real, non-symlink JSON file: ${path}`,
);
}
let value: unknown;
try {
value = JSON.parse(readFileSync(path, 'utf8')) as unknown;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${name} settings are invalid JSON: ${detail}`,
);
}
if (!isPlainObject(value)) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${name} settings must contain a JSON object.`,
);
}
return { name, path, present: true, value };
}
function accountEmail(bundleDir: string): string | undefined {
const path = join(bundleDir, 'account.json');
const info = lstatIfPresent(path);
if (!info?.isFile() || info.isSymbolicLink()) return undefined;
try {
const account = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
const oauth = isPlainObject(account['oauthAccount']) ? account['oauthAccount'] : undefined;
for (const value of [oauth?.['emailAddress'], account['emailAddress'], account['email']]) {
if (typeof value === 'string' && value.trim() !== '') return value.trim();
}
} catch {
return undefined;
}
return undefined;
}
function resolveCredential(
profile: FleetAgentLaunchProfile,
userHome: string,
seatHome: string,
): Pick<FleetLaunchComposition, 'bundle' | 'credential'> {
const authRoot = join(userHome, 'auth', profile.harness);
assertRealDirectory(authRoot, `${profile.harness} auth root`);
const bundlePath = join(authRoot, profile.bundle);
const bundleInfo = lstatIfPresent(bundlePath);
if (!bundleInfo) {
throw new FleetLaunchError('COMPOSITION_FAILED', `credential bundle not found: ${bundlePath}`);
}
if (bundleInfo.isSymbolicLink() && profile.bundle !== 'primary') {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`only the primary bundle may be an alias symlink: ${bundlePath}`,
);
}
let resolvedBundleDir: string;
try {
resolvedBundleDir = realpathSync(bundlePath);
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new FleetLaunchError('COMPOSITION_FAILED', `credential bundle cannot resolve: ${detail}`);
}
assertContained(realpathSync(authRoot), resolvedBundleDir, 'credential bundle');
assertRealDirectory(resolvedBundleDir, 'resolved credential bundle');
const credentialTarget = join(resolvedBundleDir, CREDENTIAL_FILES[profile.harness]);
const credentialInfo = lstatIfPresent(credentialTarget);
if (!credentialInfo?.isFile() || credentialInfo.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`bundle credential must be a real, non-symlink credential file: ${credentialTarget}`,
);
}
const resolvedCredential = realpathSync(credentialTarget);
assertContained(realpathSync(authRoot), resolvedCredential, 'bundle credential');
const credentialLink = join(seatHome, CREDENTIAL_FILES[profile.harness]);
const seatInfo = lstatIfPresent(credentialLink);
if (seatInfo && !seatInfo.isSymbolicLink()) {
throw new FleetLaunchError(
'FIRST_AUTH_REFUSAL',
`first-auth state detected at ${credentialLink}; refusing to delete or overwrite the real credential file. Enroll or promote it explicitly.`,
);
}
const resolvedName = basename(resolvedBundleDir);
const email = accountEmail(resolvedBundleDir);
const display =
profile.bundle === resolvedName
? `${resolvedName}${email ? ` (${email})` : ''}`
: `${profile.bundle} -> ${resolvedName}${email ? ` (${email})` : ''}`;
return {
bundle: {
requested: profile.bundle,
resolved: resolvedName,
...(email === undefined ? {} : { email }),
display,
},
credential: { link: credentialLink, target: resolvedCredential },
};
}
function resolveManagedLinks(
kind: PlannedLink['kind'],
names: readonly string[],
userHome: string,
seatHome: string,
): { installs: PlannedLink[]; prune: string[] } {
const plural = kind === 'plugin' ? 'plugins' : 'skills';
const storeRoot = join(userHome, plural);
const installRoot = join(seatHome, plural);
if (names.length > 0) assertRealDirectory(storeRoot, `${kind} store root`);
const installRootInfo = lstatIfPresent(installRoot);
if (installRootInfo?.isSymbolicLink() || (installRootInfo && !installRootInfo.isDirectory())) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${kind} install root must be a real directory (the historical whole-store symlink requires explicit migration): ${installRoot}`,
);
}
const installs: PlannedLink[] = names.map((name: string): PlannedLink => {
const target = join(storeRoot, name);
const targetInfo = lstatIfPresent(target);
if (!targetInfo?.isDirectory() || targetInfo.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${kind} store entry must be a real, non-symlink directory: ${target}`,
);
}
assertContained(realpathSync(storeRoot), realpathSync(target), `${kind} store entry`);
const link = join(installRoot, name);
const linkInfo = lstatIfPresent(link);
if (linkInfo && !linkInfo.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`real ${kind} directory occupies managed symlink path ${link}; refusing to delete it.`,
);
}
return { kind, name, link, target: realpathSync(target) };
});
const desired = new Set(names);
const prune: string[] = [];
if (installRootInfo?.isDirectory()) {
for (const entry of readdirSync(installRoot, { withFileTypes: true })) {
const path = join(installRoot, entry.name);
if (desired.has(entry.name)) continue;
if (!entry.isSymbolicLink()) {
// The harness writes its own metadata files (e.g. installed_plugins.json)
// beside the managed links; only a real directory is an unmanaged entry
// the pruner would orphan.
if (entry.isDirectory()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`real ${kind} directory occupies managed install root ${path}; refusing to prune it.`,
);
}
continue;
}
prune.push(path);
}
}
return { installs, prune };
}
function buildArgv(
profile: FleetAgentLaunchProfile,
seatHome: string,
passthrough: string[],
): string[] {
const argv: string[] = [profile.harness];
if (profile.model) argv.push('--model', profile.model);
if (profile.harness === 'pi') {
for (const skill of profile.skills) argv.push('--skill', join(seatHome, 'skills', skill));
}
argv.push(...passthrough);
return argv;
}
/** Resolve and validate the complete launch without changing the filesystem. */
export function resolveFleetLaunchComposition(
name: string,
roots: FleetLaunchRoots,
passthrough: string[] = [],
): FleetLaunchComposition {
if (!AGENT_NAME.test(name)) {
throw new FleetLaunchError('PROFILE_INVALID', `invalid fleet agent name: ${name}`);
}
const agentDir = join(roots.userHome, 'fleet', 'agents', name);
if (lstatIfPresent(agentDir) === undefined) {
throw new FleetLaunchError(
'AGENT_NOT_SCAFFOLDED',
`no such fleet agent '${name}' — run: mosaic fleet agent new ${name}`,
);
}
assertRealDirectory(agentDir, 'fleet agent directory');
const profilePath = join(agentDir, 'profile.json');
const profileInfo = lstatIfPresent(profilePath);
if (!profileInfo?.isFile() || profileInfo.isSymbolicLink()) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`agent profile must be a real, non-symlink file: ${profilePath}`,
);
}
const profile = parseFleetAgentProfile(readFileSync(profilePath, 'utf8'));
const context: FleetHarnessContext = { agentDir };
const seatHome = harnessHome(profile.harness, context);
const seatHomeInfo = lstatIfPresent(seatHome);
if (seatHomeInfo && (!seatHomeInfo.isDirectory() || seatHomeInfo.isSymbolicLink())) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`agent harness home must be a real, non-symlink directory: ${seatHome}`,
);
}
const settingsOutput = join(seatHome, 'settings.json');
const settingsSnapshot = join(agentDir, 'settings.generated.json');
for (const [label, path] of [
['generated settings', settingsOutput],
['generated settings snapshot', settingsSnapshot],
] as const) {
const info = lstatIfPresent(path);
if (info && (!info.isFile() || info.isSymbolicLink())) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`${label} path must be a real file or absent: ${path}`,
);
}
}
const overlayPath = join(agentDir, profile.overlay ?? 'overlay.json');
assertContained(agentDir, overlayPath, 'agent overlay');
const layers: SettingsLayer[] = [
readSettingsLayer(
'system',
join(roots.systemHome, 'runtime', profile.harness, 'settings.json'),
true,
),
readSettingsLayer(
'user',
join(roots.userHome, 'config', profile.harness, 'settings.json'),
false,
),
profile.overlay === undefined
? { name: 'agent', path: overlayPath, present: false, value: {} }
: readSettingsLayer('agent', overlayPath, false),
];
const merged = deepMergeSettings(...layers.map((layer) => layer.value));
const credential = resolveCredential(profile, roots.userHome, seatHome);
const plugins = resolveManagedLinks('plugin', profile.plugins, roots.userHome, seatHome);
const skills = resolveManagedLinks('skill', profile.skills, roots.userHome, seatHome);
const homeEnvName: Record<RuntimeName, string> = {
claude: 'CLAUDE_CONFIG_DIR',
pi: 'PI_CODING_AGENT_DIR',
codex: 'CODEX_HOME',
opencode: 'XDG_CONFIG_HOME',
};
const env: Record<string, string> = {
...profile.env,
[homeEnvName[profile.harness]]: seatHome,
MOSAIC_AGENT_NAME: name,
};
return {
name,
profilePath,
profile,
agentDir,
seatHome,
settings: {
layers,
merged,
output: settingsOutput,
snapshot: settingsSnapshot,
},
...credential,
installs: [...plugins.installs, ...skills.installs],
prune: [...plugins.prune, ...skills.prune],
env,
argv: buildArgv(profile, seatHome, passthrough),
};
}
function ensureSymlink(link: string, target: string): void {
const info = lstatIfPresent(link);
if (info?.isSymbolicLink()) {
const current = resolve(dirname(link), readlinkSync(link));
if (current === target) return;
rmSync(link);
} else if (info) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`real object occupies managed symlink path ${link}; refusing to delete it.`,
);
}
mkdirSync(dirname(link), { recursive: true });
symlinkSync(target, link, 'file');
}
function canonicalJson(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalJson);
if (!isPlainObject(value)) return value;
return Object.fromEntries(
Object.keys(value)
.sort((left, right) => left.localeCompare(right, 'en'))
.map((key) => [key, canonicalJson(value[key])]),
);
}
/** Apply a previously resolved plan. No caller should apply a dry-run plan. */
export function applyFleetLaunchComposition(plan: FleetLaunchComposition): void {
mkdirSync(plan.seatHome, { recursive: true });
const settings = `${JSON.stringify(canonicalJson(plan.settings.merged), null, 2)}\n`;
writeFileSync(plan.settings.output, settings, { mode: 0o600 });
writeFileSync(plan.settings.snapshot, settings, { mode: 0o600 });
ensureSymlink(plan.credential.link, plan.credential.target);
for (const path of plan.prune) {
const info = lstatIfPresent(path);
if (info?.isSymbolicLink()) rmSync(path);
else if (info) {
throw new FleetLaunchError(
'COMPOSITION_FAILED',
`real object replaced managed symlink before prune: ${path}`,
);
}
}
for (const install of plan.installs) ensureSymlink(install.link, install.target);
}
/** Stable, auditable text representation used by --dry-run and snapshot tests. */
export function formatFleetLaunchDryRun(plan: FleetLaunchComposition): string {
const lines = [
`mosaic fleet launch ${plan.name} --dry-run`,
`profile: ${plan.profilePath} (schema ${plan.profile.schema})`,
`harness: ${plan.profile.harness}`,
`seat-home: ${plan.seatHome}`,
'settings sources:',
];
for (const layer of plan.settings.layers) {
lines.push(` ${layer.name}: ${layer.path}${layer.present ? '' : ' (absent)'}`);
}
lines.push(` output: ${plan.settings.output}`);
lines.push(` snapshot: ${plan.settings.snapshot}`);
lines.push('merged settings:');
lines.push(JSON.stringify(canonicalJson(plan.settings.merged), null, 2));
lines.push(`bundle: ${plan.bundle.display}`);
lines.push('symlinks:');
lines.push(` credentials: ${plan.credential.link} -> ${plan.credential.target}`);
for (const install of plan.installs) {
lines.push(` ${install.kind} ${install.name}: ${install.link} -> ${install.target}`);
}
for (const path of plan.prune) lines.push(` prune: ${path}`);
lines.push('declared env:');
for (const key of Object.keys(plan.env).sort()) lines.push(` ${key}=${plan.env[key]}`);
lines.push(`argv: ${JSON.stringify(plan.argv)}`);
return lines.join('\n');
}
/** Register `mosaic fleet launch <name> [--dry-run]` on the fleet control plane. */
export function registerFleetLaunchCommand(
fleetCommand: Command,
systemHomeFor: () => string,
deps: FleetLaunchCommandDeps = {},
): Command {
return fleetCommand
.command('launch <name>')
.description('Compose and launch one per-agent harness home')
.option('--dry-run', 'Print the fully resolved composition without writing or launching')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((name: string, opts: { dryRun?: boolean }, command: Command): void => {
try {
const userHome = deps.userHome ?? defaultFleetDataHome();
const passthrough = command.args.slice(1);
const plan = resolveFleetLaunchComposition(
name,
{ systemHome: systemHomeFor(), userHome },
passthrough,
);
if (opts.dryRun === true) {
process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`);
return;
}
applyFleetLaunchComposition(plan);
console.log(`[mosaic] bundle: ${plan.bundle.display}`);
const launcher = deps.launcher ?? launchFleetRuntime;
launcher(plan.profile.harness, plan.argv.slice(1), plan.env, { agentDir: plan.agentDir });
} catch (error: unknown) {
process.exitCode = 1;
const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`);
}
});
}
@@ -82,6 +82,7 @@ describe('registerFleetCommand', () => {
expect(fleet).toBeDefined(); expect(fleet).toBeDefined();
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([ expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
'add', 'add',
'agent',
'apply', 'apply',
'backlog', 'backlog',
'create', 'create',
@@ -91,6 +92,7 @@ describe('registerFleetCommand', () => {
'init', 'init',
'install', 'install',
'install-systemd', 'install-systemd',
'launch',
'migrate-v1', 'migrate-v1',
'persona', 'persona',
'plan', 'plan',
+23
View File
@@ -38,6 +38,10 @@ import {
registerFleetAgentCrudCommands, registerFleetAgentCrudCommands,
type FleetAgentCrudCommandDeps, type FleetAgentCrudCommandDeps,
} from './fleet-agent-crud-command.js'; } from './fleet-agent-crud-command.js';
import {
registerFleetAgentScaffoldCommand,
type FleetAgentScaffoldCommandDeps,
} from './fleet-agent-scaffold-command.js';
import { import {
registerFleetMigrationCommand, registerFleetMigrationCommand,
type FleetMigrationCommandDeps, type FleetMigrationCommandDeps,
@@ -63,6 +67,7 @@ import { registerFleetBacklogCommand } from './fleet-backlog.js';
import { registerFleetPersonaCommand } from './fleet-personas.js'; import { registerFleetPersonaCommand } from './fleet-personas.js';
import { registerFleetProfileCommand } from './fleet-profiles.js'; import { registerFleetProfileCommand } from './fleet-profiles.js';
import { registerFleetProvisionCommand } from './fleet-provision.js'; import { registerFleetProvisionCommand } from './fleet-provision.js';
import { registerFleetLaunchCommand, type FleetLaunchCommandDeps } from './fleet-launch-command.js';
/** /**
* A function that spawns a command with inherited stdio (TTY passthrough). * A function that spawns a command with inherited stdio (TTY passthrough).
@@ -97,6 +102,10 @@ export interface FleetCommandDeps {
*/ */
sleepFn?: SleepFn; sleepFn?: SleepFn;
mosaicHome?: string; mosaicHome?: string;
/** User-owned fleet/auth/config root. Defaults to ~/.mosaic. */
mosaicUserHome?: string;
/** Test/embedding seam for the final process-replacing fleet launch. */
fleetLauncher?: FleetLaunchCommandDeps['launcher'];
frameworkRoot?: string; frameworkRoot?: string;
/** /**
* Injectable TTY check for `fleet init` wizard. Defaults to process.stdin.isTTY. * Injectable TTY check for `fleet init` wizard. Defaults to process.stdin.isTTY.
@@ -104,6 +113,8 @@ export interface FleetCommandDeps {
*/ */
isStdinTTY?: boolean; isStdinTTY?: boolean;
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier']; projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
/** Test-only user-data root for `fleet agent new` (production: ~/.mosaic). */
fleetDataHome?: FleetAgentScaffoldCommandDeps['fleetDataHome'];
reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps']; reconcileDeps?: FleetReconcilerCommandDeps['reconcileDeps'];
migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>; migrationDeps?: Omit<FleetMigrationCommandDeps, 'mosaicHome'>;
} }
@@ -2041,6 +2052,14 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// fleet/ directory as the roster and heartbeats. // fleet/ directory as the roster and heartbeats.
registerFleetBacklogCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); registerFleetBacklogCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
// User-facing per-agent profile.json is the launch-composition SSOT. It is
// intentionally independent of roster-v2, whose lifecycle/topology registry
// does not model auth bundles, overlays, plugins, skills, or seat env.
registerFleetLaunchCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome, {
...(deps.mosaicUserHome === undefined ? {} : { userHome: deps.mosaicUserHome }),
...(deps.fleetLauncher === undefined ? {} : { launcher: deps.fleetLauncher }),
});
// System-type profiles (H2): declarative persona roster + topology, resolved // System-type profiles (H2): declarative persona roster + topology, resolved
// from <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag. // from <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag.
registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
@@ -2054,6 +2073,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// profile. DRY-RUN by default; --write persists under the same --mosaic-home. // profile. DRY-RUN by default; --write persists under the same --mosaic-home.
registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
// `fleet agent new` owns user-data harness homes under ~/.mosaic. The
// existing roster-v2 CRUD remains direct fleet control-plane CRUD, so there
// is one `agent` namespace but deliberately separate state authorities.
registerFleetAgentScaffoldCommand(cmd, { fleetDataHome: deps.fleetDataHome });
// Roster-v2 desired-state mutations belong directly to the fleet control // Roster-v2 desired-state mutations belong directly to the fleet control
// plane; they do not share the root `mosaic agent` gateway-backed surface. // plane; they do not share the root `mosaic agent` gateway-backed surface.
registerFleetAgentCrudCommands(cmd, deps); registerFleetAgentCrudCommands(cmd, deps);
+141 -36
View File
@@ -35,7 +35,12 @@ import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024; const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi'; export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
/** Fleet context for the single harness-home resolution seam. */
export interface FleetHarnessContext {
readonly agentDir: string;
}
const RUNTIME_LABELS: Record<RuntimeName, string> = { const RUNTIME_LABELS: Record<RuntimeName, string> = {
claude: 'Claude Code', claude: 'Claude Code',
@@ -64,19 +69,19 @@ const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
opencode: 'XDG_CONFIG_HOME', opencode: 'XDG_CONFIG_HOME',
}; };
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */ /** Dedicated runtime home, optionally scoped to a user fleet agent. */
function harnessHome(runtime: RuntimeName): string { export function harnessHome(runtime: RuntimeName, fleet?: FleetHarnessContext): string {
return join(MOSAIC_HOME, `.${runtime}`); return join(fleet?.agentDir ?? MOSAIC_HOME, `.${runtime}`);
} }
/** /**
* Env overlay pointing a runtime at its mosaic-owned home. The directory is * Env overlay pointing a runtime at its mosaic-owned home. The directory is
* created on demand so a first launch does not fail on a missing path. * created on demand so a first launch does not fail on a missing path.
*/ */
function harnessEnv(runtime: RuntimeName): Record<string, string> { function harnessEnv(runtime: RuntimeName, fleet?: FleetHarnessContext): Record<string, string> {
const key = HARNESS_HOME_ENV[runtime]; const key = HARNESS_HOME_ENV[runtime];
if (!key) return {}; if (!key) return {};
const home = harnessHome(runtime); const home = harnessHome(runtime, fleet);
mkdirSync(home, { recursive: true }); mkdirSync(home, { recursive: true });
return { [key]: home }; return { [key]: home };
} }
@@ -162,7 +167,13 @@ function redactArgv(argv: string[]): string[] {
); );
} }
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void { function recordLaunch(
runtime: RuntimeName,
cliArgs: string[],
yolo: boolean,
fleet?: FleetHarnessContext,
launchEnv: NodeJS.ProcessEnv = process.env,
): void {
try { try {
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 }); mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
// Correlation id for the lease.register half. Set into process.env so it // Correlation id for the lease.register half. Set into process.env so it
@@ -180,13 +191,13 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): v
mode: yolo ? 'yolo' : 'normal', mode: yolo ? 'yolo' : 'normal',
cwd: process.cwd(), cwd: process.cwd(),
cli_version: CLI_VERSION, cli_version: CLI_VERSION,
config_home: harnessHome(runtime), config_home: harnessHome(runtime, fleet),
config_home_isolated: true, config_home_isolated: true,
config_home_env: HARNESS_HOME_ENV[runtime] ?? null, config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
argv: redactArgv(cliArgs), argv: redactArgv(cliArgs),
normative_fragments: normativeFragmentDigests(runtime), normative_fragments: normativeFragmentDigests(runtime),
// names only — values are never recorded // names only — values are never recorded
mosaic_env_present: Object.keys(process.env) mosaic_env_present: Object.keys(launchEnv)
.filter((k) => k.startsWith('MOSAIC_')) .filter((k) => k.startsWith('MOSAIC_'))
.sort(), .sort(),
}; };
@@ -262,9 +273,9 @@ interface SettingsAudit {
warnings: string[]; warnings: string[];
} }
function auditClaudeSettings(): SettingsAudit { function auditClaudeSettings(fleet?: FleetHarnessContext): SettingsAudit {
const warnings: string[] = []; const warnings: string[] = [];
const settingsPath = join(harnessHome('claude'), 'settings.json'); const settingsPath = join(harnessHome('claude', fleet), 'settings.json');
const settings = readJson(settingsPath); const settings = readJson(settingsPath);
if (!settings) { if (!settings) {
@@ -483,7 +494,11 @@ function buildPrdBlock(): string {
* `mosaicHome` is parameterized for testability; production callers use the * `mosaicHome` is parameterized for testability; production callers use the
* module-level default. * module-level default.
*/ */
export function composeContract(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string { export function composeContract(
runtime: RuntimeName,
mosaicHome: string = MOSAIC_HOME,
env: NodeJS.ProcessEnv = process.env,
): string {
const runtimeContractPaths: Record<RuntimeName, string> = { const runtimeContractPaths: Record<RuntimeName, string> = {
claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'), claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'), codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
@@ -540,13 +555,13 @@ For required push/merge/issue-close/release actions, execute without routine con
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal); parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
} }
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']); const fleetIdentity = resolveFleetIdentity(mosaicHome, env['MOSAIC_AGENT_NAME']);
if (!fleetIdentity.ok) { if (!fleetIdentity.ok) {
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`); throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
} }
const canonicalMember = fleetIdentity.identity?.member; const canonicalMember = fleetIdentity.identity?.member;
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) { if (canonicalMember && env['MOSAIC_AGENT_CLASS']?.trim()) {
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass; const ambientClass = canonicalizeRoleClass(env['MOSAIC_AGENT_CLASS']).canonicalClass;
if (ambientClass !== canonicalMember.className) { if (ambientClass !== canonicalMember.className) {
throw new Error( throw new Error(
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`, `Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
@@ -583,13 +598,13 @@ For required push/merge/issue-close/release actions, execute without routine con
// Fleet launches derive every identity projection from the one canonical roster // Fleet launches derive every identity projection from the one canonical roster
// member resolved above. Non-fleet launches retain the legacy ambient persona // member resolved above. Non-fleet launches retain the legacy ambient persona
// and tool-policy behavior. // and tool-policy behavior.
const personaClass = canonicalMember?.className ?? process.env['MOSAIC_AGENT_CLASS']; const personaClass = canonicalMember?.className ?? env['MOSAIC_AGENT_CLASS'];
const persona = readPersonaContractBlock(mosaicHome, personaClass); const persona = readPersonaContractBlock(mosaicHome, personaClass);
if (persona) parts.push('\n\n' + persona); if (persona) parts.push('\n\n' + persona);
const toolPolicyName = canonicalMember const toolPolicyName = canonicalMember
? canonicalMember.toolPolicy ? canonicalMember.toolPolicy
: process.env['MOSAIC_AGENT_TOOL_POLICY']; : env['MOSAIC_AGENT_TOOL_POLICY'];
const toolPolicy = readFleetToolPolicyBlock(toolPolicyName); const toolPolicy = readFleetToolPolicyBlock(toolPolicyName);
if (toolPolicy) parts.push('\n\n' + toolPolicy); if (toolPolicy) parts.push('\n\n' + toolPolicy);
@@ -613,8 +628,8 @@ function readFleetToolPolicyBlock(policy: string | undefined): string {
} }
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */ /** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
function buildRuntimePrompt(runtime: RuntimeName): string { function buildRuntimePrompt(runtime: RuntimeName, env: NodeJS.ProcessEnv = process.env): string {
return composeContract(runtime); return composeContract(runtime, MOSAIC_HOME, env);
} }
// ─── Session lock ──────────────────────────────────────────────────────────── // ─── Session lock ────────────────────────────────────────────────────────────
@@ -695,8 +710,12 @@ function checkResumableSession(): void {
// ─── Write config for runtimes that read from fixed paths ──────────────────── // ─── Write config for runtimes that read from fixed paths ────────────────────
function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void { function ensureRuntimeConfig(
const prompt = buildRuntimePrompt(runtime); runtime: RuntimeName,
destPath: string,
env: NodeJS.ProcessEnv = process.env,
): void {
const prompt = buildRuntimePrompt(runtime, env);
mkdirSync(dirname(destPath), { recursive: true }); mkdirSync(dirname(destPath), { recursive: true });
const existing = readOptional(destPath); const existing = readOptional(destPath);
if (existing !== prompt) { if (existing !== prompt) {
@@ -889,7 +908,38 @@ function getMissionPrompt(): string {
return `Active mission detected: ${mission.name}. Read the mission state files and report status.`; return `Active mission detected: ${mission.name}. Read the mission state files and report status.`;
} }
function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never { interface RuntimeLaunchContext {
readonly fleet?: FleetHarnessContext;
readonly declaredEnv?: Readonly<Record<string, string>>;
}
function minimalLaunchEnv(declared: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const name of [
'PATH',
'HOME',
'USER',
'LOGNAME',
'SHELL',
'TERM',
'COLORTERM',
'LANG',
'LC_ALL',
'TMPDIR',
'XDG_RUNTIME_DIR',
]) {
const value = process.env[name];
if (value !== undefined) env[name] = value;
}
return { ...env, ...declared };
}
function launchRuntime(
runtime: RuntimeName,
args: string[],
yolo: boolean,
context: RuntimeLaunchContext = {},
): never {
checkMosaicHome(); checkMosaicHome();
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md'); checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
checkSoul(); checkSoul();
@@ -909,14 +959,32 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
const missionStr = hasMissionNoArgs ? ' (active mission detected)' : ''; const missionStr = hasMissionNoArgs ? ' (active mission detected)' : '';
writeSessionLock(runtime); writeSessionLock(runtime);
const launchEnv =
context.declaredEnv === undefined ? process.env : minimalLaunchEnv(context.declaredEnv);
// A per-agent profile is the launch SSOT and intentionally does not require a
// second roster registry. Keep roster-v1 identity composition for legacy
// launches, but remove its identity keys from the contract-build environment
// for a profile-backed seat. The declared identity is still exported to the
// harness process below.
const contractEnv =
context.declaredEnv === undefined
? launchEnv
: Object.fromEntries(
Object.entries(launchEnv).filter(
([name]) =>
name !== 'MOSAIC_AGENT_NAME' &&
name !== 'MOSAIC_AGENT_CLASS' &&
name !== 'MOSAIC_AGENT_TOOL_POLICY',
),
);
switch (runtime) { switch (runtime) {
case 'claude': { case 'claude': {
// Audit Claude Code settings and warn about missing hooks/plugins // Audit Claude Code settings and warn about missing hooks/plugins
const settingsAudit = auditClaudeSettings(); const settingsAudit = auditClaudeSettings(context.fleet);
printSettingsWarnings(settingsAudit); printSettingsWarnings(settingsAudit);
const prompt = buildRuntimePrompt('claude'); const prompt = buildRuntimePrompt('claude', contractEnv);
const cliArgs: string[] = []; const cliArgs: string[] = [];
cliArgs.push('--append-system-prompt', prompt); cliArgs.push('--append-system-prompt', prompt);
if (hasMissionNoArgs) { if (hasMissionNoArgs) {
@@ -925,13 +993,20 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args); cliArgs.push(...args);
} }
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('claude', cliArgs, yolo); recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo); if (process.env['MOSAIC_LAUNCH_ID']) {
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
}
execLeaseGatedRuntime('claude', cliArgs, launchEnv, yolo, context.fleet);
break; break;
} }
case 'codex': { case 'codex': {
ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md')); ensureRuntimeConfig(
'codex',
join(harnessHome('codex', context.fleet), 'instructions.md'),
contractEnv,
);
const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : []; const cliArgs = yolo ? ['--dangerously-bypass-approvals-and-sandbox'] : [];
if (hasMissionNoArgs) { if (hasMissionNoArgs) {
cliArgs.push(missionPrompt); cliArgs.push(missionPrompt);
@@ -939,22 +1014,38 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args); cliArgs.push(...args);
} }
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('codex', cliArgs, yolo); recordLaunch('codex', cliArgs, yolo, context.fleet, launchEnv);
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') }); execRuntime('codex', cliArgs, {
...launchEnv,
...harnessEnv('codex', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
: {}),
});
break; break;
} }
case 'opencode': { case 'opencode': {
// opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode. // opencode follows XDG, so its config resolves to $XDG_CONFIG_HOME/opencode.
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md')); ensureRuntimeConfig(
'opencode',
join(harnessHome('opencode', context.fleet), 'opencode', 'AGENTS.md'),
contractEnv,
);
console.log(`[mosaic] Launching ${label}${modeStr}...`); console.log(`[mosaic] Launching ${label}${modeStr}...`);
recordLaunch('opencode', args, yolo); recordLaunch('opencode', args, yolo, context.fleet, launchEnv);
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') }); execRuntime('opencode', args, {
...launchEnv,
...harnessEnv('opencode', context.fleet),
...(process.env['MOSAIC_LAUNCH_ID']
? { MOSAIC_LAUNCH_ID: process.env['MOSAIC_LAUNCH_ID'] }
: {}),
});
break; break;
} }
case 'pi': { case 'pi': {
const prompt = buildRuntimePrompt('pi'); const prompt = buildRuntimePrompt('pi', contractEnv);
const cliArgs = ['--append-system-prompt', prompt]; const cliArgs = ['--append-system-prompt', prompt];
cliArgs.push(...buildPiSkillArgs(args)); cliArgs.push(...buildPiSkillArgs(args));
cliArgs.push(...discoverPiExtension()); cliArgs.push(...discoverPiExtension());
@@ -964,8 +1055,11 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
cliArgs.push(...args); cliArgs.push(...args);
} }
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`); console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
recordLaunch('pi', cliArgs, yolo); recordLaunch('pi', cliArgs, yolo, context.fleet, launchEnv);
execLeaseGatedRuntime('pi', cliArgs); if (process.env['MOSAIC_LAUNCH_ID']) {
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
}
execLeaseGatedRuntime('pi', cliArgs, launchEnv, false, context.fleet);
break; break;
} }
} }
@@ -993,6 +1087,7 @@ function execLeaseGatedRuntime(
args: string[], args: string[],
baseEnv: NodeJS.ProcessEnv = process.env, baseEnv: NodeJS.ProcessEnv = process.env,
dangerous = false, dangerous = false,
fleet?: FleetHarnessContext,
): void { ): void {
const launcher = resolveTool('lease-broker', 'launch-runtime.py'); const launcher = resolveTool('lease-broker', 'launch-runtime.py');
const dangerousArgs = dangerous ? ['--dangerous'] : []; const dangerousArgs = dangerous ? ['--dangerous'] : [];
@@ -1001,13 +1096,23 @@ function execLeaseGatedRuntime(
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args], [launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
{ {
...baseEnv, ...baseEnv,
...harnessEnv(runtime), ...harnessEnv(runtime, fleet),
MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv), MOSAIC_LEASE_BROKER_SOCKET: defaultLeaseBrokerSocket(baseEnv),
MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1', MOSAIC_RUNTIME_GENERATION: baseEnv['MOSAIC_RUNTIME_GENERATION'] ?? '1',
}, },
); );
} }
/** Fleet entry point reusing the normative runtime launch and exec path. */
export function launchFleetRuntime(
runtime: RuntimeName,
args: string[],
declaredEnv: Readonly<Record<string, string>>,
fleet: FleetHarnessContext,
): never {
return launchRuntime(runtime, args, false, { fleet, declaredEnv });
}
/** exec into the runtime, replacing the current process. */ /** exec into the runtime, replacing the current process. */
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void { function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
try { try {
@@ -0,0 +1,270 @@
import { lstat, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { isAbsolute, join, relative, resolve } from 'node:path';
export type FleetAgentHarness = 'claude' | 'pi';
export interface FleetAgentScaffoldOptions {
readonly dataHome?: string;
readonly name: string;
readonly harness?: string;
readonly bundle?: string;
readonly model?: string;
}
export interface FleetAgentScaffoldResult {
readonly agentDir: string;
readonly profile: Readonly<Record<string, unknown>>;
readonly idempotent: boolean;
readonly credentialTarget: string;
readonly credentialTargetExists: boolean;
}
export class FleetAgentScaffoldError extends Error {
readonly code: 'invalid-request' | 'agent-exists-different';
constructor(code: FleetAgentScaffoldError['code'], message: string) {
super(message);
this.name = 'FleetAgentScaffoldError';
this.code = code;
}
}
/** User-owned data root, deliberately distinct from the update-owned mosaic home. */
export function defaultFleetDataHome(): string {
return process.env['MOSAIC_DATA_HOME'] ?? join(homedir(), '.mosaic');
}
/**
* Materialize one fleet seat from authored, deterministic template content.
* Settings composition intentionally does not happen here: launch owns the
* three-layer settings merge and writes the generated settings.json then.
*/
export async function scaffoldFleetAgent(
options: FleetAgentScaffoldOptions,
): Promise<FleetAgentScaffoldResult> {
const name = requireSafeName(options.name);
const harness = requireHarness(options.harness ?? 'claude');
const bundle = requireBundle(options.bundle ?? 'primary');
const model = optionalNonEmpty(options.model, '--model');
const dataHome = resolve(options.dataHome ?? defaultFleetDataHome());
const agentDir = join(dataHome, 'fleet', 'agents', name);
const homeName = harness === 'claude' ? '.claude' : '.pi';
const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json';
const credentialTarget = join(dataHome, 'auth', harness, bundle, credentialName);
const profile: Record<string, unknown> = {
schema: 1,
harness,
bundle,
overlay: 'overlay.json',
...(model === undefined ? {} : { model }),
env: { MOSAIC_AGENT_NAME: name },
};
const entries: [string, ExpectedFile][] = [
['profile.json', { type: 'file', content: json(profile) }],
['SOUL.md', { type: 'file', content: soul(name) }],
['overlay.json', { type: 'file', content: '{}\n' }],
[
join(homeName, harness === 'claude' ? 'CLAUDE.md' : 'AGENTS.md'),
{ type: 'file', content: identityBootstrap(name) },
],
[join(homeName, credentialName), { type: 'symlink', target: credentialTarget }],
];
if (harness === 'claude') {
entries.push([
join(homeName, '.claude.json'),
{ type: 'file', content: json(onboardingState()) },
]);
}
const files = new Map<string, ExpectedFile>(entries);
const differences = await findDifferences(agentDir, files);
if (differences.length > 0) {
throw new FleetAgentScaffoldError(
'agent-exists-different',
`Agent "${name}" already exists with different user-owned file(s): ${differences.join(', ')}. Refusing to overwrite.`,
);
}
const idempotent = await pathExists(agentDir);
if (!idempotent) {
for (const [file, expected] of files) {
const path = join(agentDir, file);
await mkdir(join(path, '..'), { recursive: true, mode: 0o700 });
if (expected.type === 'file') {
await writeFile(path, expected.content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
} else {
// A dangling link is intentional before enrollment. It makes absent auth
// visible at launch instead of silently selecting another account.
await symlink(expected.target, path);
}
}
}
return {
agentDir,
profile,
idempotent,
credentialTarget,
credentialTargetExists: await pathExists(credentialTarget),
};
}
type ExpectedFile =
| { readonly type: 'file'; readonly content: string }
| { readonly type: 'symlink'; readonly target: string };
async function findDifferences(
agentDir: string,
expected: ReadonlyMap<string, ExpectedFile>,
): Promise<string[]> {
let root;
try {
root = await lstat(agentDir);
} catch (error: unknown) {
if (isMissing(error)) return [];
throw error;
}
if (!root.isDirectory() || root.isSymbolicLink()) return ['.'];
const actual = await listRelativeEntries(agentDir);
const expectedDirs = new Set<string>();
for (const path of expected.keys()) {
const parent = relative('.', join(path, '..'));
if (parent !== '') expectedDirs.add(parent);
}
const paths = new Set([
...expected.keys(),
...actual.filter((path: string): boolean => !expectedDirs.has(path)),
]);
const differences: string[] = [];
for (const path of [...paths].sort()) {
const required = expected.get(path);
if (!required) {
differences.push(path);
continue;
}
try {
const info = await lstat(join(agentDir, path));
if (required.type === 'file') {
if (
!info.isFile() ||
info.isSymbolicLink() ||
(await readFile(join(agentDir, path), 'utf8')) !== required.content
) {
differences.push(path);
}
} else if (
!info.isSymbolicLink() ||
(await readlink(join(agentDir, path))) !== required.target
) {
differences.push(path);
}
} catch (error: unknown) {
if (isMissing(error)) differences.push(path);
else throw error;
}
}
return differences;
}
async function listRelativeEntries(root: string, prefix = ''): Promise<string[]> {
const result: string[] = [];
for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
const path = join(prefix, entry.name);
if (entry.isDirectory() && !entry.isSymbolicLink()) {
result.push(path, ...(await listRelativeEntries(root, path)));
} else {
result.push(path);
}
}
return result;
}
function requireSafeName(value: string): string {
if (
typeof value !== 'string' ||
value.length === 0 ||
value === '.' ||
value === '..' ||
value.includes('/') ||
value.includes('\\') ||
value.includes('\0') ||
isAbsolute(value)
) {
throw new FleetAgentScaffoldError(
'invalid-request',
'Agent name must be one non-empty path component (not absolute or traversal).',
);
}
return value;
}
function requireHarness(value: string): FleetAgentHarness {
if (value === 'claude' || value === 'pi') return value;
throw new FleetAgentScaffoldError('invalid-request', '--harness must be claude or pi.');
}
function requireBundle(value: string): string {
if (
typeof value !== 'string' ||
value.length === 0 ||
value === '.' ||
value === '..' ||
value.includes('/') ||
value.includes('\\') ||
value.includes('\0') ||
isAbsolute(value)
) {
throw new FleetAgentScaffoldError(
'invalid-request',
'--bundle must be one non-empty auth-bundle path component.',
);
}
return value;
}
function optionalNonEmpty(value: string | undefined, option: string): string | undefined {
if (value === undefined) return undefined;
if (value.length === 0 || value.includes('\0')) {
throw new FleetAgentScaffoldError('invalid-request', `${option} must be a non-empty string.`);
}
return value;
}
function onboardingState(): Record<string, unknown> {
return { hasCompletedOnboarding: true, theme: 'dark' };
}
function soul(name: string): string {
return `# SOUL\n\n## Identity\n\nYou are ${name}, a Mosaic fleet agent seat.\n\nRole: _Describe this seat's role._\n`;
}
/** Identity is materialized by value so restricted harness modes never need to read SOUL.md. */
function identityBootstrap(name: string): string {
return `# Mosaic Fleet Agent Identity\n\nYou are ${name}, a Mosaic fleet agent seat.\n\nYour mechanical identity is ${name} (MOSAIC_AGENT_NAME). Keep this identity when working in repositories with other personas.\n`;
}
function json(value: unknown): string {
return `${JSON.stringify(value, null, 2)}\n`;
}
async function pathExists(path: string): Promise<boolean> {
try {
await lstat(path);
return true;
} catch (error: unknown) {
if (isMissing(error)) return false;
throw error;
}
}
function isMissing(error: unknown): boolean {
return (error as NodeJS.ErrnoException).code === 'ENOENT';
}
/** Guardrail kept explicit for callers that construct paths from untrusted text. */
export function isContainedInFleetDataHome(dataHome: string, path: string): boolean {
const rel = relative(resolve(dataHome), resolve(path));
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
}
@@ -23,6 +23,7 @@ COMPLETE_PATH = TOOLS / "promote-complete.py"
OBSERVER_CLIENT_PATH = TOOLS / "receipt-observer-client.py" OBSERVER_CLIENT_PATH = TOOLS / "receipt-observer-client.py"
RECEIPT_CHALLENGE_PATH = TOOLS / "receipt_challenge.py" RECEIPT_CHALLENGE_PATH = TOOLS / "receipt_challenge.py"
CLAUDE_SETTINGS = FRAMEWORK / "runtime/claude/settings.json" CLAUDE_SETTINGS = FRAMEWORK / "runtime/claude/settings.json"
CLAUDE_LEASE_OVERLAY = FRAMEWORK / "runtime/claude/lease-overlay.json"
CLAUDE_COMMAND = FRAMEWORK / "runtime/claude/commands/mosaic-promote.md" CLAUDE_COMMAND = FRAMEWORK / "runtime/claude/commands/mosaic-promote.md"
SESSION_ID = "a" * 64 SESSION_ID = "a" * 64
CHALLENGE = "b" * 64 CHALLENGE = "b" * 64
@@ -569,8 +570,15 @@ class PromotionCompleteTest(PromotionHookFixture):
class PromotionTemplateWiringTest(unittest.TestCase): class PromotionTemplateWiringTest(unittest.TestCase):
def test_gated_claude_template_wires_begin_and_ordered_stop_chain(self) -> None: def test_gated_claude_template_wires_begin_and_ordered_stop_chain(self) -> None:
settings = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8")) base = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8"))
hooks = settings["hooks"] overlay = json.loads(CLAUDE_LEASE_OVERLAY.read_text(encoding="utf-8"))
# A launched seat composes base + lease overlay; hook event arrays
# concatenate with base entries first, so wiring is asserted against
# the composed view rather than either file alone.
hooks: dict[str, list] = {}
for layer in (base["hooks"], overlay["hooks"]):
for event, groups in layer.items():
hooks.setdefault(event, []).extend(groups)
submit_commands = [ submit_commands = [
hook["command"] hook["command"]
for group in hooks["UserPromptSubmit"] for group in hooks["UserPromptSubmit"]
@@ -6,6 +6,7 @@ import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { afterEach, describe, expect, test } from 'vitest'; import { afterEach, describe, expect, test } from 'vitest';
import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js'; import { launchClaudex, type ClaudexHarnessAdapter } from '../commands/claudex.js';
import { deepMergeSettings } from '../commands/fleet-launch-command.js';
import { import {
observeAndPromoteReceiptChallenge, observeAndPromoteReceiptChallenge,
requestBrokerReply, requestBrokerReply,
@@ -42,6 +43,19 @@ const launcherPath = join(frameworkRoot, 'tools/lease-broker/launch-runtime.py')
const revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py'); const revokerPath = join(frameworkRoot, 'tools/lease-broker/revoke-lease.py');
const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md'); const compactionThreatPath = join(repositoryRoot, 'docs/architecture/compaction-revocation.md');
const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json'); const claudeSettingsPath = join(frameworkRoot, 'runtime/claude/settings.json');
const claudeLeaseOverlayPath = join(frameworkRoot, 'runtime/claude/lease-overlay.json');
// The gated seat contract is the COMPOSITION of the ungated base and the
// lease overlay (gap-7 split); assertions about lease wiring must read that
// composed view, produced by the same merge the launcher uses.
async function readGatedClaudeSettings(): Promise<Record<string, unknown>> {
const base = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as Record<string, unknown>;
const overlay = JSON.parse(await readFile(claudeLeaseOverlayPath, 'utf8')) as Record<
string,
unknown
>;
return deepMergeSettings(base, overlay);
}
const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts'); const piExtensionPath = join(frameworkRoot, 'runtime/pi/mosaic-extension.ts');
const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts'); const piLifecyclePath = join(frameworkRoot, 'runtime/pi/lease-lifecycle.ts');
const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh'); const prdyInitPath = join(frameworkRoot, 'tools/prdy/prdy-init.sh');
@@ -364,7 +378,7 @@ describe('whole mutator-class lease gate', () => {
expect(parserResult.status).toBe(0); expect(parserResult.status).toBe(0);
expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 }); expect(JSON.parse(parserResult.stdout)).toMatchObject({ gated: 0, total: 0 });
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { const settings = (await readGatedClaudeSettings()) as unknown as {
hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> }; hooks: { PreToolUse: Array<{ matcher: string; hooks: Array<{ command: string }> }> };
}; };
const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*'); const allToolsHook = settings.hooks.PreToolUse.find((hook) => hook.matcher === '.*');
@@ -660,7 +674,7 @@ describe('whole mutator-class lease gate', () => {
}); });
test('Claude and Pi compaction observer wiring is complete and fail-closed', async () => { test('Claude and Pi compaction observer wiring is complete and fail-closed', async () => {
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { const settings = (await readGatedClaudeSettings()) as unknown as {
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>; hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
}; };
expect( expect(
@@ -956,7 +970,7 @@ raise SystemExit(0 if len(session_id) == 64 and hook_present and observers_prese
await promote(socket, sessionId, pending.receipt_challenge!); await promote(socket, sessionId, pending.receipt_challenge!);
expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0); expect(runRuntimeGate(socket, sessionId, 'claude', 'Bash').status).toBe(0);
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as { const settings = (await readGatedClaudeSettings()) as unknown as {
hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> }; hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> };
}; };
expect( expect(
@@ -0,0 +1,131 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
type JsonObject = { [key: string]: Json };
const frameworkRoot = fileURLToPath(new URL('../../framework/', import.meta.url));
const basePath = `${frameworkRoot}runtime/claude/settings.json`;
const overlayPath = `${frameworkRoot}runtime/claude/lease-overlay.json`;
const gatedFixturePath = fileURLToPath(
new URL('./fixtures/claude-settings.gated.pre-split.json', import.meta.url),
);
function readJson(path: string): JsonObject {
return JSON.parse(readFileSync(path, 'utf8')) as JsonObject;
}
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
// This fixture contract composes hook event arrays additively. It is deliberately
// limited to verifying that the split is lossless; production launch merge
// semantics remain owned by W-F1.
function deepMerge(base: Json, overlay: Json): Json {
if (Array.isArray(base) && Array.isArray(overlay)) {
const merged = [...base];
for (const entry of overlay) {
if (!isObject(entry) || !Array.isArray(entry['hooks'])) {
merged.push(entry);
continue;
}
const matchingIndex = merged.findIndex(
(candidate) =>
isObject(candidate) &&
Array.isArray(candidate['hooks']) &&
candidate['matcher'] === entry['matcher'],
);
if (matchingIndex === -1) merged.push(entry);
else merged[matchingIndex] = deepMerge(merged[matchingIndex]!, entry);
}
return merged;
}
if (isObject(base) && isObject(overlay)) {
const merged: JsonObject = { ...base };
for (const [key, value] of Object.entries(overlay)) {
merged[key] = key in merged ? deepMerge(merged[key]!, value) : value;
}
return merged;
}
return overlay;
}
function normalize(value: Json): Json {
if (Array.isArray(value)) {
return value
.map(normalize)
.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
}
if (isObject(value)) {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, normalize(nested)]),
);
}
return value;
}
function hookCommands(settings: JsonObject): string[] {
const hooks = settings['hooks'];
if (!isObject(hooks)) return [];
return Object.values(hooks).flatMap((event) => {
if (!Array.isArray(event)) return [];
return event.flatMap((entry) => {
if (!isObject(entry) || !Array.isArray(entry['hooks'])) return [];
return entry['hooks'].flatMap((hook) =>
isObject(hook) && typeof hook['command'] === 'string' ? [hook['command']] : [],
);
});
});
}
const sequentialThinking: JsonObject = {
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
};
describe('canonical Claude base and lease-promotion overlay', () => {
it('keeps every lease command out of the ungated base', () => {
const base = readJson(basePath);
const commands = hookCommands(base);
for (const marker of ['mutator-gate', 'receipt-observer', 'promote-', 'revoke-lease']) {
expect(
commands.some((command) => command.includes(marker)),
marker,
).toBe(false);
}
});
it('reconstructs the pre-split gated hooks while retaining the canonical MCP correction', () => {
const base = readJson(basePath);
const overlay = readJson(overlayPath);
const preSplit = readJson(gatedFixturePath);
const expected: JsonObject = {
...preSplit,
mcpServers: { 'sequential-thinking': sequentialThinking },
};
expect(normalize(deepMerge(base, overlay))).toEqual(normalize(expected));
});
it('ships sequential-thinking in the base', () => {
const base = readJson(basePath);
expect(base['mcpServers']).toEqual({ 'sequential-thinking': sequentialThinking });
});
it('limits the overlay to lease hook entries', () => {
const overlay = readJson(overlayPath);
expect(Object.keys(overlay)).toEqual(['hooks']);
const commands = hookCommands(overlay);
expect(commands).toHaveLength(6);
for (const command of commands) {
expect(command).toMatch(/mutator-gate|receipt-observer|promote-|revoke-lease/);
}
});
});
@@ -0,0 +1,319 @@
{
"model": "opus",
"hooks": {
"PreCompact": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
}
]
}
],
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
}
]
},
{
"matcher": "resume|clear",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "^/mosaic-promote$",
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
"timeout": 15
}
]
}
],
"PreToolUse": [
{
"matcher": ".*",
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
"timeout": 3
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh",
"timeout": 10
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "~/.config/mosaic/tools/qa/qa-hook-stdin.sh",
"timeout": 60
}
]
},
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "~/.config/mosaic/tools/qa/typecheck-hook.sh",
"timeout": 30
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
"timeout": 15
},
{
"type": "command",
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
"timeout": 15
}
]
}
]
},
"enabledPlugins": {
"frontend-design@claude-plugins-official": true,
"feature-dev@claude-plugins-official": true,
"code-review@claude-plugins-official": true,
"pr-review-toolkit@claude-plugins-official": true
},
"skipDangerousModePermissionPrompt": true,
"allowedCommands": [
"npm",
"npm install",
"npm run",
"npm test",
"npm build",
"npm start",
"npm run dev",
"npm run build",
"npm run lint",
"npm run typecheck",
"npm run test:ci",
"npm run test:e2e",
"npm run test:unit",
"npm run test:integration",
"npm run test:cov",
"npm run test:security",
"npm run security:scan",
"npm run security:audit",
"npm run performance:benchmark",
"npm run build:dev",
"npm run build:prod",
"npm run test",
"npm run test:watch",
"npm run migrate",
"npm run migrate:rollback",
"npm run db:seed",
"npm run db:reset",
"node",
"yarn",
"pnpm",
"npx",
"npx tsc",
"npx eslint",
"npx prettier",
"npx jest",
"npx vitest",
"git",
"git add",
"git commit",
"git push",
"git pull",
"git status",
"git diff",
"git log",
"git branch",
"git checkout",
"git merge",
"git init",
"git remote",
"git fetch",
"git reset",
"git rebase",
"git stash",
"git tag",
"git show",
"git config",
"gh",
"gh issue",
"gh pr",
"gh repo",
"gh api",
"docker",
"docker build",
"docker run",
"docker ps",
"docker logs",
"docker exec",
"docker stop",
"docker start",
"docker pull",
"docker push",
"docker-compose",
"docker-compose up",
"docker-compose down",
"docker-compose build",
"docker-compose logs",
"docker-compose ps",
"docker-compose exec",
"kubectl",
"kubectl get",
"kubectl describe",
"kubectl logs",
"kubectl apply",
"kubectl delete",
"kubectl port-forward",
"mkdir",
"touch",
"chmod",
"chown",
"ls",
"cd",
"pwd",
"cp",
"mv",
"rm",
"cat",
"echo",
"head",
"tail",
"grep",
"grep -E",
"grep -r",
"find",
"find -name",
"find -type",
"find -path",
"find -exec",
"find . -type f",
"find . -type d",
"wc",
"sort",
"uniq",
"curl",
"wget",
"ping",
"netstat",
"ss",
"lsof",
"psql",
"pg_dump",
"pg_restore",
"sqlite3",
"jest",
"vitest",
"playwright",
"cypress",
"artillery",
"lighthouse",
"tsc",
"eslint",
"prettier",
"snyk",
"semgrep",
"tar",
"gzip",
"unzip",
"zip",
"which",
"whoami",
"id",
"env",
"export",
"source",
"sleep",
"date",
"uptime",
"df",
"du",
"free",
"top",
"htop",
"ps",
"tree",
"jq",
"sed",
"awk",
"xargs",
"tee",
"test",
"true",
"false",
"basename",
"dirname",
"realpath",
"readlink",
"stat",
"file",
"make",
"cmake",
"gcc",
"g++",
"clang",
"python",
"python3",
"pip",
"pip3",
"pip install",
"poetry",
"pipenv",
"go",
"go build",
"go test",
"go run",
"go mod",
"cargo",
"rustc",
"ruby",
"gem",
"bundle",
"rake",
"java",
"javac",
"mvn",
"gradle",
"dotnet",
"msbuild",
"php",
"composer",
"perl",
"cpan",
"nohup"
],
"enableAllMcpTools": true
}