feat(fleet): HARNESS-HOMES fleet MVP — profile-driven seat composition and launch (#1209) #1213
@@ -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
@@ -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.
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user