Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d7a932d8d |
@@ -81,6 +81,73 @@ pnpm format:check # Prettier check
|
|||||||
pnpm build # Build all packages and applications
|
pnpm build # Build all packages and applications
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Branch Model and Merge Process — `main` and `next` (CANONICAL)
|
||||||
|
|
||||||
|
**Every contribution targets `next` first. No exceptions.** Features, fixes, tests,
|
||||||
|
docs, and policy changes all take the same route; urgency changes queue priority,
|
||||||
|
never the route. Agents never commit to or merge into `main`.
|
||||||
|
|
||||||
|
| Branch | Role | Who merges into it |
|
||||||
|
| ------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| `next` | Integration trunk — the only PR target for contributions | The designated merge-gate agent, after all gates pass. Never the PR author. |
|
||||||
|
| `main` | Stable/release line — receives promotion merges from `next` only | Jason only (or an agent he explicitly delegates for a named promotion). |
|
||||||
|
|
||||||
|
### Contribution sequencing (in order, no skipping)
|
||||||
|
|
||||||
|
1. **Issue first.** Work is tracked in a Gitea issue before a branch exists. The
|
||||||
|
issue number appears in the branch name and the PR body.
|
||||||
|
2. **Branch from the current `origin/next` head.** Name it
|
||||||
|
`feat/…`, `fix/…`, `docs/…`, or `test/…` with the issue number
|
||||||
|
(e.g. `docs/1214-branch-process`). Record the base SHA in the PR body.
|
||||||
|
3. **Develop with evidence.** Applicable tests accompany the change. Hooks are
|
||||||
|
never bypassed (`--no-verify` is prohibited). Stage explicit paths — never
|
||||||
|
`git add -A`.
|
||||||
|
4. **Open the PR against `next`.** The body states: scope, base SHA,
|
||||||
|
verification commands with results, and any known pre-existing failures on
|
||||||
|
the base — documented, not retried to green and not absorbed silently.
|
||||||
|
5. **CI must be terminal-green on the exact head.** All bounded Woodpecker
|
||||||
|
steps succeed (`verify-terminal-green` contract). Pipelines for fork PRs
|
||||||
|
start `blocked`; a maintainer approves the run — approving CI is not
|
||||||
|
approving the PR.
|
||||||
|
6. **Independent review. Self-merge is prohibited** — for every agent, on every
|
||||||
|
PR, including trivial ones. Where the change touches protected or
|
||||||
|
contract-bearing content, the reviewer verifies the exact head
|
||||||
|
(exact-byte/exact-blob comparison), not a description of it. An `AMEND`
|
||||||
|
verdict returns the PR to its author; the reviewer's gate stays held until
|
||||||
|
a fresh exact head passes.
|
||||||
|
7. **Merge into `next`** happens only after CI green + review pass, pinned to
|
||||||
|
the reviewed head SHA (a post-review push voids the review).
|
||||||
|
8. **Promotion `next` → `main`** is a deliberate, Jason-owned reconciliation
|
||||||
|
merge — not part of any contribution's lifecycle. Contributors are done at
|
||||||
|
step 7.
|
||||||
|
|
||||||
|
### Responsibilities
|
||||||
|
|
||||||
|
- **Contributor** — base pinning, green CI, evidence in the PR body,
|
||||||
|
responding to AMEND verdicts, never merging own work.
|
||||||
|
- **Reviewer / merge gate** — independent verification on the exact head;
|
||||||
|
holds and lifts gates; executes the merge into `next`.
|
||||||
|
- **Orchestrator / adjudicator** — cross-PR sequencing, disposition when PRs
|
||||||
|
collide, conflict adjudication.
|
||||||
|
- **Jason** — `next` → `main` promotions, merge-authority grants, collaborator
|
||||||
|
and token provisioning. Agents cannot grant themselves or each other any of
|
||||||
|
these.
|
||||||
|
|
||||||
|
### Hotfixes and divergence
|
||||||
|
|
||||||
|
- A hotfix follows the same path: branch from `next`, PR to `next`, gates,
|
||||||
|
merge, then an expedited Jason-owned promotion if `main` needs it urgently.
|
||||||
|
Committing the fix to `main` directly is prohibited even under pressure.
|
||||||
|
- **Never land work on `main` that is not on `next`.** This has happened
|
||||||
|
(issue #1152's goal controller reached `main` without reaching `next`) and
|
||||||
|
every later PR paid for it. If it happens anyway: transplant the work onto
|
||||||
|
a `next`-based branch with provenance-preserving commits
|
||||||
|
(`git cherry-pick -x` or explicit SHA references in the messages), PR it
|
||||||
|
through the normal gates, and let promotion re-align `main`. Do not
|
||||||
|
hand-patch `main` to compensate.
|
||||||
|
- Force-pushing a branch you do not own is prohibited; rebasing your own PR
|
||||||
|
branch is fine before review, and voids any review already given.
|
||||||
|
|
||||||
## Database and Local Runtime Safety
|
## Database and Local Runtime Safety
|
||||||
|
|
||||||
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
# 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
@@ -1,102 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
# AMD1213-D — transaction and helper trust remediation
|
|
||||||
|
|
||||||
- **Task:** AMD1213-D (issue #1213 amendment; controlling packet `comms/20260813T212447Z__from-scrappy__dc43de.md`)
|
|
||||||
- **Objective:** Address D1–D6 on local `feat/wf-fleet-mvp`, commit-only. Never push or re-author.
|
|
||||||
- **Scope:** Existing C-fence production/tests only. No provider calls.
|
|
||||||
- **Standing constraint:** AMEND/HOLD. Do not push, do not update PR #1213, do not merge, do not
|
|
||||||
re-author. PR #1216 remains independently held for Jason.
|
|
||||||
|
|
||||||
## Where this actually stands (measured 2026-08-15, not inherited from notes)
|
|
||||||
|
|
||||||
Everything below was re-measured against the tree rather than trusted from the previous entries,
|
|
||||||
which understated progress by roughly two defects. Branch head `3667a7a7`.
|
|
||||||
|
|
||||||
| Defect | State | Evidence |
|
|
||||||
|---|---|---|
|
|
||||||
| D1 transactional rollback | **Confirmed** — controls exist and are strong | see below; one narrow gap (in-memory link restoration unasserted) |
|
|
||||||
| D2 exact managed-link classes | Substantially implemented | classifier at `fleet-launch-command.ts:598-663`: exact resolved credential, direct one-component plugin/skill only, symlink target and ancestor rejected, `realpath` containment, duplicates rejected |
|
|
||||||
| D3 ambient-PATH executable resolution | **Closed** — executables `585dac7a`, environment `3667a7a7` | see below; one deliberate residual (HOME) |
|
|
||||||
| D4 config check/apply safety | Substantially implemented | `secure_dir` ancestor checks, `read_private` with `O_NOFOLLOW` + fstat, apply via mkstemp + fchmod 0600 + fsync + dev/ino re-check before `os.replace`, compatibility path separated |
|
|
||||||
| D5 bounded test seam | **Closed this pass** (commit `b91b702a`) | see below |
|
|
||||||
| D6 validate-by-path then exec-by-path | Substantially implemented | helper runs as a verified snapshot piped to `bash -s`, not executed by pathname; the same binding applied to the runtime in D3 |
|
|
||||||
|
|
||||||
### D1 — confirmed, and a correction to my own survey
|
|
||||||
|
|
||||||
I first reported that eleven `injectFailure` seams existed in production and **zero tests used
|
|
||||||
any of them**, and that D1's rollback path had never been executed. That was wrong. The grep
|
|
||||||
behind it searched for the identifier `injectFailure` in the specs; the specs supply the injector
|
|
||||||
as an inline lambda, so the controls were there and the search could not see them. Method note
|
|
||||||
for the next survey: grep the production seam names, not the parameter name.
|
|
||||||
|
|
||||||
The controls that exist, all in `fleet-launch-command.spec.ts`:
|
|
||||||
|
|
||||||
- **All ten mutation seams**, table-driven — `mkdir-seat`, `prepare-manifest`, `write-settings`,
|
|
||||||
`write-snapshot`, `credential-link`, `prune-link`, `install-link`, `write-manifest`,
|
|
||||||
`close-manifest`, `rename-manifest`. Each asserts byte-for-byte restoration of six artifacts
|
|
||||||
(settings bytes, settings mode, generated snapshot, manifest, credential symlink target, plugin
|
|
||||||
symlink target) plus the absence of the `.tmp` manifest.
|
|
||||||
- **New-seat rollback** — a failure on a seat the transaction itself created leaves no directory
|
|
||||||
and no residue.
|
|
||||||
- **`ROLLBACK_INTEGRITY` escalation** — three parent-substitution attacks (symlink swap, inode
|
|
||||||
replacement, rename away) each produce a typed refusal, leave an external sentinel untouched,
|
|
||||||
and write `.mosaic-fleet-launch-recovery.json`. A replacement of the transaction-created seat
|
|
||||||
is likewise refused rather than deleted.
|
|
||||||
|
|
||||||
That is a real RED→GREEN matrix, not an implementation read as done.
|
|
||||||
|
|
||||||
**Gap, narrow:** nothing asserts the in-memory restoration of `plan.managedLinks.links` to
|
|
||||||
`manifestLinksBefore` — the filesystem is checked, the plan object is not. It matters only if a
|
|
||||||
caller reuses a plan after catching a failure, which nothing currently does, so this is
|
|
||||||
defence-in-depth rather than a live defect. Worth one assertion when D2/D4/D6 are confirmed.
|
|
||||||
|
|
||||||
### D2 — one thing worth recording so it is not "fixed" later
|
|
||||||
|
|
||||||
The alias concern in the packet (`duplicates/normalization aliases`) is closed by strictness, not
|
|
||||||
by normalization. `dirname(link)` is compared literally against the seat root, so `/s/plugins//foo`
|
|
||||||
(`dirname` → `/s/plugins/`), `/s/plugins/./foo` and `/s/plugins/bar/../foo` all fail the comparison
|
|
||||||
and are rejected. Verified by direct measurement of `path.dirname` on each form. Anyone who
|
|
||||||
"improves" this by normalizing the link first would open the alias hole the strict comparison
|
|
||||||
currently closes.
|
|
||||||
|
|
||||||
### D3 — what was wrong and what was done
|
|
||||||
|
|
||||||
The launcher asked `which` whether a runtime was reachable and then spawned the **bare name**,
|
|
||||||
letting the OS resolve it a second time against an ambient PATH at a later moment. Two independent
|
|
||||||
resolutions of an attacker-influenced name with a gap between them.
|
|
||||||
|
|
||||||
Demonstrated against the old code before changing it — a world-writable `codex` shim prepended to
|
|
||||||
PATH:
|
|
||||||
|
|
||||||
```
|
|
||||||
OLD checkRuntime -> PASSED (which found it)
|
|
||||||
OLD execRuntime -> "SHIM EXECUTED — this is not the real runtime"
|
|
||||||
```
|
|
||||||
|
|
||||||
Three exposed call sites, not one: `checkRuntime`'s `which`; `execRuntime` spawning
|
|
||||||
`codex`/`opencode` by name; and `execLeaseGatedRuntime` spawning `python3` by name — the
|
|
||||||
interpreter that starts the lease gate, where a shim replaces the process that enforces every other
|
|
||||||
check. `minimalLaunchEnv` copies ambient PATH straight through.
|
|
||||||
|
|
||||||
Fix: `resolveExecutableFromPath` searches only the PATH the child will actually receive, validates
|
|
||||||
what the search lands on (regular file, executable, not group/other-writable, owned by the
|
|
||||||
launching user or root, no group/world-writable non-sticky directory and no foreign-owned directory
|
|
||||||
on the resolved path), and returns that path pinned to dev/ino. Callers execute the returned path
|
|
||||||
and never the name again. The fleet lease-gate interpreter comes from the root-owned
|
|
||||||
`trustedCapability('python3')`. `checkRuntime` is deliberately kept on the operator path, where
|
|
||||||
"is it reachable from my shell" is the right question.
|
|
||||||
|
|
||||||
**Residuals, stated not engineered around:**
|
|
||||||
|
|
||||||
1. `assertUnchangedSinceValidation` re-confirms dev/ino immediately before spawn. That narrows the
|
|
||||||
validation→exec window; it does not close it. Closing it means exec by held descriptor, which
|
|
||||||
Node cannot do portably. Same accepted boundary already documented for the fleet helper.
|
|
||||||
2. For claude and pi the runtime binary is still re-resolved inside `launch-runtime.py` after the
|
|
||||||
trusted interpreter starts it. **This change does not cover that path.**
|
|
||||||
|
|
||||||
12 tests, one per hole. One was written wrong first and is worth remembering:
|
|
||||||
`mkdirSync(path, { mode: 0o777 })` is masked by the umask to 0o755, so the world-writable-directory
|
|
||||||
case passed while testing nothing. Create at 0o755, then `chmodSync`.
|
|
||||||
|
|
||||||
### D3 environment half — measured, and mostly already true
|
|
||||||
|
|
||||||
Measured before changing anything: the real `fleet launch` route with a shim in place of the
|
|
||||||
runtime binary, the shim dumping its own environment. The subject is therefore what arrives after
|
|
||||||
composition **and** after `launch-runtime.py` adds the lease variables — not the object the
|
|
||||||
launcher builds. Those are different sets.
|
|
||||||
|
|
||||||
The complete child environment for a composed claude seat:
|
|
||||||
|
|
||||||
```
|
|
||||||
PATH HOME USER LOGNAME SHELL TERM COLORTERM TMPDIR XDG_RUNTIME_DIR (inherited allowlist)
|
|
||||||
LANG LC_ALL (fixed, this pass)
|
|
||||||
CLAUDE_CONFIG_DIR MOSAIC_AGENT_NAME <profile env> (declared)
|
|
||||||
MOSAIC_LAUNCH_ID (minted per launch)
|
|
||||||
MOSAIC_LEASE_BROKER_SOCKET MOSAIC_LEASE_GENERATION_FILE
|
|
||||||
MOSAIC_LEASE_RUNTIME MOSAIC_LEASE_SESSION_ID
|
|
||||||
MOSAIC_RECEIPT_OBSERVER_SOCKET MOSAIC_RUNTIME_GENERATION (lease gate)
|
|
||||||
```
|
|
||||||
|
|
||||||
Most of the defect was already closed **by construction and untested**. `minimalLaunchEnv` builds
|
|
||||||
from an empty object over a fixed list, so `BASH_ENV`, `ENV`, `PYTHON*`, `NODE_*`, `NPM_CONFIG_*`,
|
|
||||||
`LD_PRELOAD`, `LD_LIBRARY_PATH` and provider credentials never reach the child. All sixteen were
|
|
||||||
planted; none survived, including through the lease gate. The gap was that nothing named the
|
|
||||||
allowlist, and an allowlist no test names is one careless edit away from being a denylist.
|
|
||||||
|
|
||||||
Fixed: **locale was inherited**, so the same seat emitted different message language, collation and
|
|
||||||
number/date formatting depending on who started it. Composed launches now pin `C.UTF-8` — not `C`,
|
|
||||||
which is ASCII and would mangle non-ASCII output. A profile-declared `LANG`/`LC_ALL` still wins,
|
|
||||||
and a test holds that escape hatch open. The operator path is untouched.
|
|
||||||
|
|
||||||
**Residual, deliberate — `HOME` is still the operator's.** The card is right that this is the
|
|
||||||
remaining leak: the runtime gets its own config dir, but anything it shells out to (git, ssh, npm)
|
|
||||||
reads the operator's dotfiles and therefore the operator's credentials. Not changed here, because
|
|
||||||
a seat whose HOME is a bare directory has no gitconfig and no ssh key, so it cannot commit or push
|
|
||||||
— and the fleet MVP's proof is a seat carrying a change to a pushed branch. Moving HOME before the
|
|
||||||
per-agent home is populated improves isolation and breaks the deliverable. **Owner: the
|
|
||||||
harness-homes design**, which is exactly the track that populates a per-agent home with its own
|
|
||||||
auth bundle. Do it there, not here.
|
|
||||||
|
|
||||||
Eight tests, each falsified by inverting the property it defends; every inversion hit only its own
|
|
||||||
test: `BASH_ENV` added to the inherited list → permitted-set + loader-hook killers red (2 failed);
|
|
||||||
locale pin reverted → locale killer red; ambient `MOSAIC_LAUNCH_ID` reused → launch-id killer red;
|
|
||||||
`process.env` recorded into the ledger → ledger-value killer red.
|
|
||||||
|
|
||||||
The permitted-name list in the spec is hand-written, not derived from the launcher. Deriving it
|
|
||||||
would make the test agree with the code by construction and detect nothing.
|
|
||||||
|
|
||||||
### D5 — what remained and what was done
|
|
||||||
|
|
||||||
Most of D5 was already closed: `launchFleetRuntimeForTest` is gone, specs enter through the real
|
|
||||||
`registerFleetLaunchCommand → apply → launchFleetRuntime → launchRuntime` route on a fixture seat,
|
|
||||||
the ledger points at the fixture and **is** asserted, and the seat-seeded/HOME-empty pass plus
|
|
||||||
HOME-seeded/seat-empty fail pair both exist.
|
|
||||||
|
|
||||||
What remained was the dead `recordLaunch?: boolean` context field. Nothing in the package set it;
|
|
||||||
its only effect was to let a caller silently disable recording on the claude branch while codex,
|
|
||||||
opencode and pi recorded unconditionally. Removed.
|
|
||||||
|
|
||||||
## Not part of D1–D6, fixed because it blocked the required evidence
|
|
||||||
|
|
||||||
The amend requires a green full-package Vitest run.
|
|
||||||
`install-ordering-guard.spec.ts > defaults to the real leaseEnforcementActivatable()` made that
|
|
||||||
non-reproducible. `defaultCapabilityProbe` executes `dist/cli.js` out-of-process with a **2000 ms
|
|
||||||
timeout**; in a full run with 86 spec files scheduled at once, one observation beats the timeout and
|
|
||||||
the next does not, so the test's two observations of the same predicate disagree and it fails —
|
|
||||||
reporting machine load as a wiring defect. Passed 3/3 in isolation, failed in three consecutive
|
|
||||||
full runs.
|
|
||||||
|
|
||||||
Ruled out my own change by reverting only the `recordLaunch` edit and re-running: still failed.
|
|
||||||
The guard call is now bracketed by two observations, only an agreeing pair is used as ground truth,
|
|
||||||
a disagreeing pair is retried up to three times, and never holding still is a failure rather than a
|
|
||||||
skip. Falsified by inverting the guard's default to `!leaseEnforcementActivatable()` → red
|
|
||||||
(1 failed / 18 passed), then reverted.
|
|
||||||
|
|
||||||
## Verification state
|
|
||||||
|
|
||||||
- typecheck RC=0.
|
|
||||||
- Full package Vitest, sanitized lease env (`MOSAIC_LEASE_*` + `MOSAIC_RUNTIME_GENERATION`
|
|
||||||
stripped): **87 files / 1627 tests passed, 0 failed**, three consecutive runs plus one against
|
|
||||||
the committed tree, RC=0. That is exactly one file and eight tests above the 86/1619 baseline,
|
|
||||||
so the D3 environment work moved nothing else. eslint RC=0, prettier clean.
|
|
||||||
- Without that sanitization the suite shows 4 failures in `mutator-gate.acceptance.spec.ts`. Those
|
|
||||||
are the known host lease-identity leak into spawned hooks, **not** a product defect — the same
|
|
||||||
spec re-run with only those five variables stripped and no code change is 20/20. The standing fix
|
|
||||||
is the unpushed `fix/lease-test-env-isolation` branch (blocked on the identity blocker below).
|
|
||||||
|
|
||||||
## Still open
|
|
||||||
|
|
||||||
- **D2/D4/D6 need confirmation, not assumption.** They read as substantially implemented but I
|
|
||||||
have not run the packet's full RED→GREEN control matrix against each seam. D1 is now confirmed
|
|
||||||
(see above), D3 and D5 are closed; these three are the remaining item.
|
|
||||||
- **D3's HOME residual** is routed to harness-homes (see above). It is stated, not engineered
|
|
||||||
around, and it does not belong to this branch.
|
|
||||||
- Required next evidence per the packet: all D1–D6 observed RED→GREEN controls, framework-shell,
|
|
||||||
build/lint/Prettier/bash -n, fresh current-next merge-tree.
|
|
||||||
|
|
||||||
## Blocker not solvable inside this branch
|
|
||||||
|
|
||||||
No `fred` principal exists (`tea login list` has no entry; `MOSAIC_GIT_IDENTITY` never reaches the
|
|
||||||
pane). The only push path on this host is the **retired** mos-dt-0 token. That is why this work is
|
|
||||||
commit-only beyond scrappy's instruction — even after the hold lifts, the truthful authenticated
|
|
||||||
push the packet requires cannot be made under a correct identity yet. Raised with mos-claude and
|
|
||||||
with Jason; awaiting a mint decision.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "Write|Edit|MultiEdit",
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh",
|
|
||||||
"timeout": 10
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
|
||||||
"timeout": 15
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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,7 +1,60 @@
|
|||||||
{
|
{
|
||||||
"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": [
|
||||||
@@ -38,6 +91,11 @@
|
|||||||
"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",
|
||||||
@@ -257,11 +315,5 @@
|
|||||||
"cpan",
|
"cpan",
|
||||||
"nohup"
|
"nohup"
|
||||||
],
|
],
|
||||||
"enableAllMcpTools": true,
|
"enableAllMcpTools": true
|
||||||
"mcpServers": {
|
|
||||||
"sequential-thinking": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Fleet launches execute this source through an already-validated absolute bash
|
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||||
# capability and pass all interpreter capabilities explicitly. Do not add PATH
|
|
||||||
# lookup here: this helper is intentionally capability-minimal.
|
|
||||||
MODE="apply"
|
MODE="apply"
|
||||||
RUNTIME="all"
|
RUNTIME="all"
|
||||||
STRICT_CHECK=0
|
STRICT_CHECK=0
|
||||||
CLAUDE_CONFIG_DIR=""
|
|
||||||
PYTHON_BIN=""
|
|
||||||
NODE_BIN=""
|
|
||||||
NPX_BIN=""
|
|
||||||
TIMEOUT_BIN=""
|
|
||||||
PKG="@modelcontextprotocol/server-sequential-thinking"
|
PKG="@modelcontextprotocol/server-sequential-thinking"
|
||||||
|
|
||||||
err() { echo "[mosaic-seq] ERROR: $*" >&2; }
|
err() { echo "[mosaic-seq] ERROR: $*" >&2; }
|
||||||
@@ -19,130 +13,250 @@ log() { echo "[mosaic-seq] $*"; }
|
|||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--check) MODE="check"; shift ;;
|
--check)
|
||||||
--runtime) RUNTIME="${2:?--runtime requires a value}"; shift 2 ;;
|
MODE="check"
|
||||||
--strict) STRICT_CHECK=1; shift ;;
|
shift
|
||||||
--claude-config-dir) CLAUDE_CONFIG_DIR="${2:?--claude-config-dir requires a value}"; shift 2 ;;
|
;;
|
||||||
--python-bin) PYTHON_BIN="${2:?--python-bin requires a value}"; shift 2 ;;
|
--runtime)
|
||||||
--node-bin) NODE_BIN="${2:?--node-bin requires a value}"; shift 2 ;;
|
if [[ $# -lt 2 ]]; then
|
||||||
--npx-bin) NPX_BIN="${2:?--npx-bin requires a value}"; shift 2 ;;
|
err "--runtime requires a value: claude|codex|opencode|all"
|
||||||
--timeout-bin) TIMEOUT_BIN="${2:?--timeout-bin requires a value}"; shift 2 ;;
|
exit 2
|
||||||
*) err "Unknown argument: $1"; exit 2 ;;
|
fi
|
||||||
|
RUNTIME="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--strict)
|
||||||
|
STRICT_CHECK=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
err "Unknown argument: $1"
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
case "$RUNTIME" in all|claude|codex|opencode) ;; *) err "Invalid runtime: $RUNTIME"; exit 2;; esac
|
|
||||||
# Explicit fleet-seat operation is capability-minimal. Legacy operator repair
|
case "$RUNTIME" in
|
||||||
# keeps its documented PATH-based compatibility contract.
|
all|claude|codex|opencode) ;;
|
||||||
if [[ -n "$CLAUDE_CONFIG_DIR" || -n "$PYTHON_BIN$NODE_BIN$NPX_BIN$TIMEOUT_BIN" ]]; then
|
*)
|
||||||
[[ -n "$PYTHON_BIN" && -n "$NODE_BIN" && -n "$NPX_BIN" ]] || { err "Fleet capabilities are required"; exit 2; }
|
err "Invalid runtime: $RUNTIME (expected claude|codex|opencode|all)"
|
||||||
else
|
exit 2
|
||||||
PYTHON_BIN=python3
|
;;
|
||||||
NODE_BIN=node
|
esac
|
||||||
NPX_BIN=npx
|
|
||||||
TIMEOUT_BIN=timeout
|
require_binary() {
|
||||||
fi
|
local name="$1"
|
||||||
|
if ! command -v "$name" >/dev/null 2>&1; then
|
||||||
|
err "Required binary missing: $name"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_software() {
|
||||||
|
require_binary node
|
||||||
|
require_binary npx
|
||||||
|
}
|
||||||
|
|
||||||
warm_package() {
|
warm_package() {
|
||||||
local timeout_sec="${MOSAIC_SEQ_WARM_TIMEOUT_SEC:-15}"
|
local timeout_sec="${MOSAIC_SEQ_WARM_TIMEOUT_SEC:-15}"
|
||||||
if [[ -n "$TIMEOUT_BIN" ]]; then "$TIMEOUT_BIN" "$timeout_sec" "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
else "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1; fi
|
timeout "$timeout_sec" npx -y "$PKG" --help >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
npx -y "$PKG" --help >/dev/null 2>&1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
claude_config_python='import json, os, stat, tempfile
|
check_claude_config() {
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
p = Path.home() / ".claude" / "settings.json"
|
||||||
|
if not p.exists():
|
||||||
|
raise SystemExit(1)
|
||||||
|
try:
|
||||||
|
data = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit(1)
|
||||||
|
mcp = data.get("mcpServers")
|
||||||
|
if not isinstance(mcp, dict):
|
||||||
|
raise SystemExit(1)
|
||||||
|
entry = mcp.get("sequential-thinking")
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise SystemExit(1)
|
||||||
|
if entry.get("command") != "npx":
|
||||||
|
raise SystemExit(1)
|
||||||
|
args = entry.get("args")
|
||||||
|
if args != ["-y", "@modelcontextprotocol/server-sequential-thinking"]:
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
def die(): raise SystemExit(1)
|
apply_claude_config() {
|
||||||
def secure_dir(p):
|
python3 - <<'PY'
|
||||||
p=Path(p)
|
import json
|
||||||
if not p.is_absolute(): die()
|
from pathlib import Path
|
||||||
# Every parent may be sticky /tmp, but none may be a symlink. The fleet
|
p = Path.home() / ".claude" / "settings.json"
|
||||||
# config root itself must be private and owned by the invoking principal.
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
for q in [p, *p.parents]:
|
if p.exists():
|
||||||
try: s=os.lstat(q)
|
|
||||||
except OSError: die()
|
|
||||||
if stat.S_ISLNK(s.st_mode) or not stat.S_ISDIR(s.st_mode): die()
|
|
||||||
if q != p and s.st_mode & 0o022 and not (s.st_mode & stat.S_ISVTX): die()
|
|
||||||
s=os.lstat(p)
|
|
||||||
if s.st_uid not in (os.geteuid(), 0) or s.st_mode & 0o022: die()
|
|
||||||
return p
|
|
||||||
|
|
||||||
def read_private(p):
|
|
||||||
try: fd=os.open(p, os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
|
||||||
except OSError: die()
|
|
||||||
try:
|
try:
|
||||||
s=os.fstat(fd)
|
data = json.loads(p.read_text(encoding="utf-8"))
|
||||||
if not stat.S_ISREG(s.st_mode) or s.st_uid not in (os.geteuid(),0) or s.st_mode & 0o077 or s.st_size>1048576: die()
|
except Exception:
|
||||||
data=b""
|
data = {}
|
||||||
while len(data)<=1048576:
|
else:
|
||||||
c=os.read(fd,65536)
|
data = {}
|
||||||
if not c: break
|
mcp = data.get("mcpServers")
|
||||||
data+=c
|
if not isinstance(mcp, dict):
|
||||||
if len(data)>1048576: die()
|
mcp = {}
|
||||||
return data, (s.st_dev,s.st_ino)
|
mcp["sequential-thinking"] = {
|
||||||
finally: os.close(fd)
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
||||||
|
}
|
||||||
|
data["mcpServers"] = mcp
|
||||||
|
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
def entry_ok(data):
|
check_codex_config() {
|
||||||
try: d=json.loads(data.decode()); e=d.get("mcpServers",{}).get("sequential-thinking",{})
|
local cfg="$HOME/.codex/config.toml"
|
||||||
except Exception: return False
|
[[ -f "$cfg" ]] || return 1
|
||||||
return e.get("command")=="npx" and e.get("args")==["-y","@modelcontextprotocol/server-sequential-thinking"]
|
grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \
|
||||||
|
grep -q '^command = "npx"' "$cfg" && \
|
||||||
|
grep -q '@modelcontextprotocol/server-sequential-thinking' "$cfg"
|
||||||
|
}
|
||||||
|
|
||||||
def explicit_check_or_apply(apply):
|
apply_codex_config() {
|
||||||
root=secure_dir(os.environ["CLAUDE_CONFIG_DIR"]); p=root/".claude.json"
|
local cfg="$HOME/.codex/config.toml"
|
||||||
if not apply: return 0 if entry_ok(read_private(str(p))[0]) else 1
|
mkdir -p "$(dirname "$cfg")"
|
||||||
old={}; identity=None
|
[[ -f "$cfg" ]] || touch "$cfg"
|
||||||
if os.path.lexists(p):
|
|
||||||
raw,identity=read_private(str(p))
|
local tmp
|
||||||
try: old=json.loads(raw.decode())
|
tmp="$(mktemp)"
|
||||||
except Exception: old={}
|
awk '
|
||||||
mcp=old.get("mcpServers") if isinstance(old.get("mcpServers"),dict) else {}
|
BEGIN { skip = 0 }
|
||||||
mcp["sequential-thinking"]={"command":"npx","args":["-y","@modelcontextprotocol/server-sequential-thinking"]}; old["mcpServers"]=mcp
|
/^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]/ { skip = 1; next }
|
||||||
fd,tmp=tempfile.mkstemp(prefix=".claude.json.",dir=root)
|
skip && /^\[/ { skip = 0 }
|
||||||
|
!skip { print }
|
||||||
|
' "$cfg" > "$tmp"
|
||||||
|
mv "$tmp" "$cfg"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo ""
|
||||||
|
echo "[mcp_servers.sequential-thinking]"
|
||||||
|
echo "command = \"npx\""
|
||||||
|
echo "args = [\"-y\", \"@modelcontextprotocol/server-sequential-thinking\"]"
|
||||||
|
} >> "$cfg"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_opencode_config() {
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
p = Path.home() / ".config" / "opencode" / "config.json"
|
||||||
|
if not p.exists():
|
||||||
|
raise SystemExit(1)
|
||||||
|
try:
|
||||||
|
data = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit(1)
|
||||||
|
mcp = data.get("mcp")
|
||||||
|
if not isinstance(mcp, dict):
|
||||||
|
raise SystemExit(1)
|
||||||
|
entry = mcp.get("sequential-thinking")
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise SystemExit(1)
|
||||||
|
if entry.get("type") != "local":
|
||||||
|
raise SystemExit(1)
|
||||||
|
if entry.get("command") != ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"]:
|
||||||
|
raise SystemExit(1)
|
||||||
|
if entry.get("enabled") is not True:
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_opencode_config() {
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
p = Path.home() / ".config" / "opencode" / "config.json"
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if p.exists():
|
||||||
try:
|
try:
|
||||||
os.fchmod(fd,0o600); os.write(fd,(json.dumps(old,indent=2)+"\n").encode()); os.fsync(fd); os.close(fd)
|
data = json.loads(p.read_text(encoding="utf-8"))
|
||||||
try: now=os.lstat(p); current=(now.st_dev,now.st_ino)
|
except Exception:
|
||||||
except FileNotFoundError: current=None
|
data = {}
|
||||||
if current!=identity: die()
|
else:
|
||||||
os.replace(tmp,p)
|
data = {}
|
||||||
finally:
|
mcp = data.get("mcp")
|
||||||
try: os.close(fd)
|
if not isinstance(mcp, dict):
|
||||||
except OSError: pass
|
mcp = {}
|
||||||
try: os.unlink(tmp)
|
mcp["sequential-thinking"] = {
|
||||||
except FileNotFoundError: pass
|
"type": "local",
|
||||||
return 0
|
"command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"],
|
||||||
|
"enabled": True
|
||||||
|
}
|
||||||
|
data["mcp"] = mcp
|
||||||
|
p.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
if os.environ.get("CLAUDE_CONFIG_DIR"):
|
check_runtime_config() {
|
||||||
raise SystemExit(explicit_check_or_apply(os.environ.get("SEQ_APPLY")=="1"))
|
case "$RUNTIME" in
|
||||||
# Compatibility path is intentionally not fleet-authoritative.
|
all)
|
||||||
p=Path.home()/".claude.json"
|
check_claude_config
|
||||||
if not p.exists() and not os.environ.get("SEQ_APPLY")=="1": p=Path.home()/".claude"/"settings.json"
|
check_codex_config
|
||||||
if os.environ.get("SEQ_APPLY")=="1":
|
check_opencode_config
|
||||||
try: d=json.loads(p.read_text()) if p.exists() else {}
|
;;
|
||||||
except Exception: d={}
|
claude)
|
||||||
m=d.get("mcpServers") if isinstance(d.get("mcpServers"),dict) else {}
|
check_claude_config
|
||||||
m["sequential-thinking"]={"command":"npx","args":["-y","@modelcontextprotocol/server-sequential-thinking"]}; d["mcpServers"]=m
|
;;
|
||||||
p.parent.mkdir(parents=True,exist_ok=True); p.write_text(json.dumps(d,indent=2)+"\n")
|
codex)
|
||||||
raise SystemExit(0)
|
check_codex_config
|
||||||
try: raise SystemExit(0 if entry_ok(p.read_bytes()) else 1)
|
;;
|
||||||
except Exception: raise SystemExit(1)'
|
opencode)
|
||||||
|
check_opencode_config
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
check_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=0 "$PYTHON_BIN" -c "$claude_config_python"; }
|
apply_runtime_config() {
|
||||||
apply_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=1 "$PYTHON_BIN" -c "$claude_config_python"; }
|
case "$RUNTIME" in
|
||||||
check_codex_config() { CODEX_CFG="${CODEX_HOME:-$HOME/.codex}/config.toml" "$PYTHON_BIN" -c 'import os,re; from pathlib import Path; s=Path(os.environ["CODEX_CFG"]).read_text(); ok=bool(re.search(r"^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]",s,re.M) and "command = \"npx\"" in s and "@modelcontextprotocol/server-sequential-thinking" in s); raise SystemExit(0 if ok else 1)'; }
|
all)
|
||||||
apply_codex_config() { CODEX_CFG="${CODEX_HOME:-$HOME/.codex}/config.toml" "$PYTHON_BIN" -c 'import os,re; from pathlib import Path; p=Path(os.environ["CODEX_CFG"]); p.parent.mkdir(parents=True,exist_ok=True); out=[]; skip=False
|
apply_claude_config
|
||||||
for line in (p.read_text().splitlines() if p.exists() else []):
|
apply_codex_config
|
||||||
if re.match(r"^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]$",line): skip=True; continue
|
apply_opencode_config
|
||||||
if skip and line.startswith("["): skip=False
|
;;
|
||||||
if not skip: out.append(line)
|
claude)
|
||||||
p.write_text("\n".join(out).rstrip()+"\n\n[mcp_servers.sequential-thinking]\ncommand = \"npx\"\nargs = [\"-y\", \"@modelcontextprotocol/server-sequential-thinking\"]\n")'; }
|
apply_claude_config
|
||||||
check_opencode_config() { XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" "$PYTHON_BIN" -c 'import json,os; from pathlib import Path; p=Path(os.environ["XDG_CONFIG_HOME"])/"opencode/config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home()/".config/opencode/config.json"; d=json.loads(p.read_text()); e=d.get("mcp",{}).get("sequential-thinking"); expected={"type":"local","command":["npx","-y","@modelcontextprotocol/server-sequential-thinking"],"enabled":True}; raise SystemExit(0 if e==expected else 1)' ; }
|
;;
|
||||||
apply_opencode_config() { XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" "$PYTHON_BIN" -c 'import json,os; from pathlib import Path; p=Path(os.environ["XDG_CONFIG_HOME"])/"opencode/config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home()/".config/opencode/config.json"; p.parent.mkdir(parents=True,exist_ok=True); d=json.loads(p.read_text()) if p.exists() else {}; m=d.get("mcp") if isinstance(d.get("mcp"),dict) else {}; m["sequential-thinking"]={"type":"local","command":["npx","-y","@modelcontextprotocol/server-sequential-thinking"],"enabled":True}; d["mcp"]=m; p.write_text(json.dumps(d,indent=2)+"\n")'; }
|
codex)
|
||||||
check_runtime_config() { case "$RUNTIME" in all) check_claude_config && check_codex_config && check_opencode_config;; claude) check_claude_config;; codex) check_codex_config;; opencode) check_opencode_config;; esac; }
|
apply_codex_config
|
||||||
apply_runtime_config() { case "$RUNTIME" in claude) apply_claude_config;; codex) apply_codex_config;; opencode) apply_opencode_config;; all) apply_claude_config && apply_codex_config && apply_opencode_config;; esac; }
|
;;
|
||||||
if [[ "$MODE" == check ]]; then
|
opencode)
|
||||||
|
apply_opencode_config
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$MODE" == "check" ]]; then
|
||||||
|
check_software
|
||||||
check_runtime_config
|
check_runtime_config
|
||||||
if [[ "$STRICT_CHECK" == 1 || "${MOSAIC_SEQ_CHECK_WARM:-0}" == 1 ]]; then warm_package || { err "sequential-thinking package warm-up failed in strict mode"; exit 1; }; fi
|
|
||||||
log "sequential-thinking MCP is configured and available (${RUNTIME})"; exit 0
|
# Runtime launch checks should be local/fast by default.
|
||||||
|
if [[ "$STRICT_CHECK" -eq 1 || "${MOSAIC_SEQ_CHECK_WARM:-0}" == "1" ]]; then
|
||||||
|
if ! warm_package; then
|
||||||
|
err "sequential-thinking package warm-up failed in strict mode"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "sequential-thinking MCP is configured and available (${RUNTIME})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_software
|
||||||
|
if ! warm_package; then
|
||||||
|
err "Unable to warm sequential-thinking package (npx timeout/failure)"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
warm_package || { err "sequential-thinking package warm-up failed"; exit 1; }
|
|
||||||
apply_runtime_config
|
apply_runtime_config
|
||||||
log "sequential-thinking MCP configured (${RUNTIME})"
|
log "sequential-thinking MCP configured (${RUNTIME})"
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
||||||
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');
|
|
||||||
const mosaicHome = join(root!, 'installed-mosaic');
|
|
||||||
mkdirSync(join(mosaicHome, 'runtime', 'claude'), { recursive: true });
|
|
||||||
writeFileSync(
|
|
||||||
join(mosaicHome, 'runtime', 'claude', 'settings.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
mcpServers: {
|
|
||||||
'sequential-thinking': {
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
registerFleetAgentScaffoldCommand(fleet, {
|
|
||||||
fleetDataHome: dataHome,
|
|
||||||
mosaicHomeFor: () => mosaicHome,
|
|
||||||
});
|
|
||||||
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/.mosaic-managed-links.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(JSON.parse(await readFile(join(agent, '.claude', '.claude.json'), 'utf8'))).toEqual({
|
|
||||||
hasCompletedOnboarding: true,
|
|
||||||
theme: 'dark',
|
|
||||||
mcpServers: {
|
|
||||||
'sequential-thinking': {
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const credentialTarget = join(dataHome, 'auth', 'claude', 'primary', '.credentials.json');
|
|
||||||
expect(await readlink(join(agent, '.claude', '.credentials.json'))).toBe(credentialTarget);
|
|
||||||
expect(
|
|
||||||
JSON.parse(await readFile(join(agent, '.claude', '.mosaic-managed-links.json'), 'utf8')),
|
|
||||||
).toEqual({
|
|
||||||
links: { [join(agent, '.claude', '.credentials.json')]: credentialTarget },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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/.mosaic-managed-links.json',
|
|
||||||
'.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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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;
|
|
||||||
/** Resolves the active installed Mosaic root that owns the canonical runtime base. */
|
|
||||||
readonly mosaicHomeFor?: () => 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 }),
|
|
||||||
...(deps.mosaicHomeFor === undefined ? {} : { mosaicHome: deps.mosaicHomeFor() }),
|
|
||||||
});
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,6 @@ 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',
|
||||||
@@ -92,7 +91,6 @@ describe('registerFleetCommand', () => {
|
|||||||
'init',
|
'init',
|
||||||
'install',
|
'install',
|
||||||
'install-systemd',
|
'install-systemd',
|
||||||
'launch',
|
|
||||||
'migrate-v1',
|
'migrate-v1',
|
||||||
'persona',
|
'persona',
|
||||||
'plan',
|
'plan',
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ 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,
|
||||||
@@ -67,7 +63,6 @@ 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).
|
||||||
@@ -102,10 +97,6 @@ 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.
|
||||||
@@ -113,8 +104,6 @@ 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'>;
|
||||||
}
|
}
|
||||||
@@ -2052,14 +2041,6 @@ 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);
|
||||||
@@ -2073,13 +2054,6 @@ 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, {
|
|
||||||
...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }),
|
|
||||||
mosaicHomeFor: () => cmd.opts<{ mosaicHome: string }>().mosaicHome,
|
|
||||||
});
|
|
||||||
// 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);
|
||||||
|
|||||||
@@ -201,34 +201,10 @@ describe('guardClaudeSettingsWiring', () => {
|
|||||||
// red-first). Instead it proves the wiring is genuinely delegated: the
|
// red-first). Instead it proves the wiring is genuinely delegated: the
|
||||||
// no-deps call must agree with an explicit call to the same real
|
// no-deps call must agree with an explicit call to the same real
|
||||||
// predicate, not some other hardcoded value.
|
// predicate, not some other hardcoded value.
|
||||||
//
|
const reallyActivatable = leaseEnforcementActivatable();
|
||||||
// The predicate is observed on BOTH sides of the guard call because it is
|
const outcome = guardClaudeSettingsWiring(fixtureJson());
|
||||||
// not deterministic under load. `defaultCapabilityProbe` executes
|
|
||||||
// `dist/cli.js` out-of-process with a 2s timeout; in a full-package run
|
|
||||||
// with every spec file scheduled at once, one observation can beat that
|
|
||||||
// timeout while the next does not. Comparing a single before-observation
|
|
||||||
// against the guard's own internal call therefore failed intermittently
|
|
||||||
// in the full suite while passing in isolation — the test was reporting
|
|
||||||
// machine load as a wiring defect.
|
|
||||||
//
|
|
||||||
// So the guard call is bracketed by two observations and only a pair that
|
|
||||||
// agrees is used as ground truth. A disagreeing pair proves the
|
|
||||||
// environment moved mid-test rather than that the wiring is wrong, and is
|
|
||||||
// retried. This does not weaken the assertion: a delegation failure is
|
|
||||||
// stable and survives every attempt, while load noise does not. Three
|
|
||||||
// attempts that never hold still is itself a failure, so an environment
|
|
||||||
// that is permanently unstable is reported rather than skipped.
|
|
||||||
let before = false;
|
|
||||||
let outcome: ReturnType<typeof guardClaudeSettingsWiring> | undefined;
|
|
||||||
for (let attempt = 0; attempt < 3 && outcome === undefined; attempt += 1) {
|
|
||||||
before = leaseEnforcementActivatable();
|
|
||||||
const candidate = guardClaudeSettingsWiring(fixtureJson());
|
|
||||||
if (leaseEnforcementActivatable() === before) outcome = candidate;
|
|
||||||
}
|
|
||||||
expect(outcome, 'activation probe never held still across three attempts').toBeDefined();
|
|
||||||
if (outcome === undefined) return;
|
|
||||||
|
|
||||||
if (before) {
|
if (reallyActivatable) {
|
||||||
expect(outcome.exitCode).toBe(0);
|
expect(outcome.exitCode).toBe(0);
|
||||||
expect(outcome.wired).toBe(true);
|
expect(outcome.wired).toBe(true);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,338 +0,0 @@
|
|||||||
/**
|
|
||||||
* What the launched runtime actually receives in its environment.
|
|
||||||
*
|
|
||||||
* These tests do not inspect `minimalLaunchEnv` and do not use a test seam. They run the real
|
|
||||||
* `fleet launch` route -- register, apply, compose, lease gate, exec -- with a shim standing in
|
|
||||||
* for the runtime binary, and the shim dumps its own environment. So the thing under test is the
|
|
||||||
* environment at the far end of the whole chain, after `launch-runtime.py` has added the lease
|
|
||||||
* variables, rather than the object the launcher believed it was building. The two differ, and
|
|
||||||
* only the first one matters.
|
|
||||||
*
|
|
||||||
* The property being defended: an operator's environment is large, grows over time, and contains
|
|
||||||
* names that make a child execute code before its first instruction (`BASH_ENV`, `PYTHONSTARTUP`,
|
|
||||||
* `NODE_OPTIONS`, `LD_PRELOAD`) as well as credentials for accounts the seat is deliberately not
|
|
||||||
* pegged to. A composed seat must receive a closed set of names, and "closed" is only true if
|
|
||||||
* something measures it.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
chmodSync,
|
|
||||||
existsSync,
|
|
||||||
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 { registerFleetLaunchCommand } from './fleet-launch-command.js';
|
|
||||||
|
|
||||||
const roots: string[] = [];
|
|
||||||
afterEach(() => {
|
|
||||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Names permitted to reach a composed seat, written out rather than derived from the launcher.
|
|
||||||
*
|
|
||||||
* Deriving it would make the test agree with the code by construction and detect nothing. The
|
|
||||||
* cost of a literal list is that adding a variable means editing this file, which is the point:
|
|
||||||
* a new name in a seat's environment should be a decision someone made, not a side effect.
|
|
||||||
*
|
|
||||||
* PWD, SHLVL and `_` are absent because the shim's own shell sets them after exec; they are
|
|
||||||
* filtered at the measurement site, not permitted here.
|
|
||||||
*/
|
|
||||||
const PERMITTED_CHILD_ENV = new Set([
|
|
||||||
// inherited from the operator by the launcher's allowlist
|
|
||||||
'PATH',
|
|
||||||
'HOME',
|
|
||||||
'USER',
|
|
||||||
'LOGNAME',
|
|
||||||
'SHELL',
|
|
||||||
'TERM',
|
|
||||||
'COLORTERM',
|
|
||||||
'TMPDIR',
|
|
||||||
'XDG_RUNTIME_DIR',
|
|
||||||
// fixed by the launcher
|
|
||||||
'LANG',
|
|
||||||
'LC_ALL',
|
|
||||||
// declared by the seat profile and composition
|
|
||||||
'CLAUDE_CONFIG_DIR',
|
|
||||||
'MOSAIC_AGENT_NAME',
|
|
||||||
'SEAT_FLAG',
|
|
||||||
// minted per launch for ledger correlation
|
|
||||||
'MOSAIC_LAUNCH_ID',
|
|
||||||
// added by the lease gate in launch-runtime.py
|
|
||||||
'MOSAIC_LEASE_BROKER_SOCKET',
|
|
||||||
'MOSAIC_LEASE_GENERATION_FILE',
|
|
||||||
'MOSAIC_LEASE_RUNTIME',
|
|
||||||
'MOSAIC_LEASE_SESSION_ID',
|
|
||||||
'MOSAIC_RECEIPT_OBSERVER_SOCKET',
|
|
||||||
'MOSAIC_RUNTIME_GENERATION',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Operator environment that must not survive composition.
|
|
||||||
*
|
|
||||||
* Three classes, all real. Loader hooks run attacker-chosen code inside the runtime before it
|
|
||||||
* does anything (`BASH_ENV`/`ENV` for shells, `PYTHON*` for the interpreter that runs the lease
|
|
||||||
* gate, `NODE_*` for the runtime itself, `LD_*` for every dynamically linked binary in the tree).
|
|
||||||
* Package configuration redirects where code is fetched from. Provider credentials belong to the
|
|
||||||
* operator's accounts, and a seat pegged to its own auth bundle that can still read them is not
|
|
||||||
* pegged to anything.
|
|
||||||
*
|
|
||||||
* The values are distinctive so the diagnostics check below can search for them by content.
|
|
||||||
*/
|
|
||||||
const OPERATOR_ONLY_ENV: Record<string, string> = {
|
|
||||||
BASH_ENV: '/poison-a1b2/bash_env.sh',
|
|
||||||
ENV: '/poison-a1b2/env.sh',
|
|
||||||
PYTHONPATH: '/poison-a1b2/pythonpath',
|
|
||||||
PYTHONSTARTUP: '/poison-a1b2/pythonstartup.py',
|
|
||||||
NODE_OPTIONS: '--require /poison-a1b2/preload.js',
|
|
||||||
NODE_PATH: '/poison-a1b2/node_path',
|
|
||||||
NPM_CONFIG_PREFIX: '/poison-a1b2/npm_prefix',
|
|
||||||
NPM_CONFIG_REGISTRY: 'https://poison-a1b2.example.invalid/',
|
|
||||||
LD_PRELOAD: '/poison-a1b2/preload.so',
|
|
||||||
LD_LIBRARY_PATH: '/poison-a1b2/lib',
|
|
||||||
ANTHROPIC_API_KEY: 'poison-a1b2-anthropic-key',
|
|
||||||
OPENAI_API_KEY: 'poison-a1b2-openai-key',
|
|
||||||
GH_TOKEN: 'poison-a1b2-github-token',
|
|
||||||
GITEA_TOKEN: 'poison-a1b2-gitea-token',
|
|
||||||
AWS_SECRET_ACCESS_KEY: 'poison-a1b2-aws-secret',
|
|
||||||
SSH_AUTH_SOCK: '/poison-a1b2/ssh-agent.sock',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Fixture {
|
|
||||||
root: string;
|
|
||||||
systemHome: string;
|
|
||||||
userHome: string;
|
|
||||||
agentDir: string;
|
|
||||||
seatHome: string;
|
|
||||||
bin: string;
|
|
||||||
dump: string;
|
|
||||||
ledger: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fixture(profileEnv: Record<string, string>, extraProfile: Record<string, unknown> = {}) {
|
|
||||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-child-env-'));
|
|
||||||
roots.push(root);
|
|
||||||
const systemHome = join(root, 'system');
|
|
||||||
const userHome = join(root, 'user');
|
|
||||||
const agentDir = join(userHome, 'fleet', 'agents', 'fred');
|
|
||||||
const seatHome = join(agentDir, '.claude');
|
|
||||||
const namedBundleDir = join(userHome, 'auth', 'claude', 'fred_example.com');
|
|
||||||
const bin = join(root, 'bin');
|
|
||||||
const dump = join(root, 'child-env.txt');
|
|
||||||
|
|
||||||
mkdirSync(join(systemHome, 'runtime', 'claude'), { recursive: true });
|
|
||||||
mkdirSync(join(systemHome, 'tools', '_scripts'), { recursive: true });
|
|
||||||
mkdirSync(seatHome, { recursive: true });
|
|
||||||
mkdirSync(namedBundleDir, { recursive: true });
|
|
||||||
mkdirSync(bin, { recursive: true });
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
join(agentDir, 'profile.json'),
|
|
||||||
`${JSON.stringify({ schema: 1, harness: 'claude', env: profileEnv, ...extraProfile }, 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');
|
|
||||||
|
|
||||||
writeFileSync(join(systemHome, 'AGENTS.md'), '# fixture\n');
|
|
||||||
writeFileSync(join(systemHome, 'SOUL.md'), '# fixture\n');
|
|
||||||
const frameworkSettings = readFileSync(
|
|
||||||
join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json'),
|
|
||||||
);
|
|
||||||
writeFileSync(join(systemHome, 'runtime', 'claude', 'settings.json'), frameworkSettings);
|
|
||||||
writeFileSync(
|
|
||||||
join(systemHome, 'runtime', 'claude', 'RUNTIME.md'),
|
|
||||||
readFileSync(join(process.cwd(), 'framework', 'runtime', 'claude', 'RUNTIME.md')),
|
|
||||||
);
|
|
||||||
const helper = join(systemHome, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
|
||||||
writeFileSync(
|
|
||||||
helper,
|
|
||||||
readFileSync(
|
|
||||||
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
|
||||||
),
|
|
||||||
{ mode: 0o700 },
|
|
||||||
);
|
|
||||||
chmodSync(helper, 0o700);
|
|
||||||
writeFileSync(join(seatHome, '.claude.json'), frameworkSettings.toString(), { mode: 0o600 });
|
|
||||||
|
|
||||||
// The shim records its own environment and exits. `claude` is the measurement point; `python3`
|
|
||||||
// is present only so a PATH lookup for it would succeed -- the lease gate deliberately takes the
|
|
||||||
// root-owned interpreter instead, so this copy should never run, and the assertions below do not
|
|
||||||
// depend on which one does.
|
|
||||||
for (const name of ['claude', 'python3']) {
|
|
||||||
const path = join(bin, name);
|
|
||||||
writeFileSync(
|
|
||||||
path,
|
|
||||||
`#!/usr/bin/env bash\nenv > ${JSON.stringify(`${dump}.${name}`)}\nexit 0\n`,
|
|
||||||
{
|
|
||||||
mode: 0o700,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
chmodSync(path, 0o700);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
root,
|
|
||||||
systemHome,
|
|
||||||
userHome,
|
|
||||||
agentDir,
|
|
||||||
seatHome,
|
|
||||||
bin,
|
|
||||||
dump,
|
|
||||||
ledger: join(systemHome, 'fleet', 'run', 'sessions', 'events.ndjson'),
|
|
||||||
} satisfies Fixture;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run the real launch route with a controlled operator environment. */
|
|
||||||
function launch(fx: Fixture, operatorEnv: Record<string, string>): Map<string, string> {
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
const fleet = program.command('fleet');
|
|
||||||
// The launcher execs and then exits; the fixture runtime returns instead, so the exit is the
|
|
||||||
// normal end of this route rather than a failure.
|
|
||||||
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
|
||||||
throw new Error('process.exit called');
|
|
||||||
});
|
|
||||||
const saved = new Map<string, string | undefined>();
|
|
||||||
const set = (name: string, value: string): void => {
|
|
||||||
saved.set(name, process.env[name]);
|
|
||||||
process.env[name] = value;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const [name, value] of Object.entries(OPERATOR_ONLY_ENV)) set(name, value);
|
|
||||||
for (const [name, value] of Object.entries(operatorEnv)) set(name, value);
|
|
||||||
saved.set('PATH', process.env['PATH']);
|
|
||||||
process.env['PATH'] = `${fx.bin}:${process.env['PATH'] ?? ''}`;
|
|
||||||
registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome });
|
|
||||||
try {
|
|
||||||
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
|
|
||||||
} catch {
|
|
||||||
// exec replaced by the mocked exit above
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
exit.mockRestore();
|
|
||||||
for (const [name, value] of saved) {
|
|
||||||
if (value === undefined) delete process.env[name];
|
|
||||||
else process.env[name] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = `${fx.dump}.claude`;
|
|
||||||
if (!existsSync(path)) throw new Error('runtime shim never ran; nothing was measured');
|
|
||||||
const env = new Map<string, string>();
|
|
||||||
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
||||||
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(line);
|
|
||||||
// Names the shim's own shell sets after exec, not names the launcher passed.
|
|
||||||
if (match && !['PWD', 'SHLVL', '_', 'OLDPWD'].includes(match[1]!))
|
|
||||||
env.set(match[1]!, match[2]!);
|
|
||||||
}
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPERATOR_BASELINE: Record<string, string> = {
|
|
||||||
LANG: 'en_US.UTF-8',
|
|
||||||
LC_ALL: 'en_US.UTF-8',
|
|
||||||
TERM: 'xterm-256color',
|
|
||||||
COLORTERM: 'truecolor',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('composed seat child environment', () => {
|
|
||||||
it('hands the runtime no name outside the permitted set', () => {
|
|
||||||
const fx = fixture({ SEAT_FLAG: 'yes' });
|
|
||||||
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
|
||||||
|
|
||||||
const unexpected = [...env.keys()].filter((name) => !PERMITTED_CHILD_ENV.has(name)).sort();
|
|
||||||
expect(
|
|
||||||
unexpected,
|
|
||||||
'a name reached the seat that nobody declared; add it to PERMITTED_CHILD_ENV only if it belongs there',
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops operator loader hooks, package configuration, and provider credentials', () => {
|
|
||||||
const fx = fixture({ SEAT_FLAG: 'yes' });
|
|
||||||
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
|
||||||
|
|
||||||
const survivors = Object.keys(OPERATOR_ONLY_ENV)
|
|
||||||
.filter((name) => env.has(name))
|
|
||||||
.sort();
|
|
||||||
expect(survivors, 'operator-only variables reached the seat').toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives the runtime the declared seat values, not the operator equivalents', () => {
|
|
||||||
const fx = fixture({ SEAT_FLAG: 'yes' });
|
|
||||||
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
|
||||||
|
|
||||||
expect(env.get('CLAUDE_CONFIG_DIR')).toBe(fx.seatHome);
|
|
||||||
expect(env.get('MOSAIC_AGENT_NAME')).toBe('fred');
|
|
||||||
expect(env.get('SEAT_FLAG')).toBe('yes');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fixes the locale instead of inheriting the operator locale', () => {
|
|
||||||
const fx = fixture({ SEAT_FLAG: 'yes' });
|
|
||||||
const env = launch(fx, {
|
|
||||||
LANG: 'de_DE.UTF-8',
|
|
||||||
LC_ALL: 'de_DE.UTF-8',
|
|
||||||
TERM: 'xterm-256color',
|
|
||||||
COLORTERM: 'truecolor',
|
|
||||||
HOME: join(fx.root, 'operator-home'),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(env.get('LANG')).toBe('C.UTF-8');
|
|
||||||
expect(env.get('LC_ALL')).toBe('C.UTF-8');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets a seat that needs a different locale declare one', () => {
|
|
||||||
const fx = fixture({ SEAT_FLAG: 'yes', LANG: 'de_DE.UTF-8', LC_ALL: 'de_DE.UTF-8' });
|
|
||||||
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
|
||||||
|
|
||||||
expect(env.get('LANG')).toBe('de_DE.UTF-8');
|
|
||||||
expect(env.get('LC_ALL')).toBe('de_DE.UTF-8');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inherits the allowlisted operator values it is supposed to inherit', () => {
|
|
||||||
const fx = fixture({ SEAT_FLAG: 'yes' });
|
|
||||||
const env = launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
|
||||||
|
|
||||||
expect(env.get('TERM')).toBe('xterm-256color');
|
|
||||||
expect(env.get('COLORTERM')).toBe('truecolor');
|
|
||||||
expect(env.get('PATH')).toContain(fx.bin);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('mints a fresh launch id rather than forwarding the operator session id', () => {
|
|
||||||
const fx = fixture({ SEAT_FLAG: 'yes' });
|
|
||||||
const env = launch(fx, {
|
|
||||||
...OPERATOR_BASELINE,
|
|
||||||
HOME: join(fx.root, 'operator-home'),
|
|
||||||
MOSAIC_LAUNCH_ID: 'operator-session-launch-id',
|
|
||||||
});
|
|
||||||
|
|
||||||
const childId = env.get('MOSAIC_LAUNCH_ID');
|
|
||||||
expect(childId).toBeDefined();
|
|
||||||
expect(childId).not.toBe('operator-session-launch-id');
|
|
||||||
// The id is only useful if the ledger records the same one; correlation is by this value and
|
|
||||||
// never by pid, because exec makes the runtime a different process.
|
|
||||||
expect(readFileSync(fx.ledger, 'utf8')).toContain(`"launch_id":"${childId}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps operator environment values out of the launch ledger', () => {
|
|
||||||
const fx = fixture({ SEAT_FLAG: 'yes' });
|
|
||||||
launch(fx, { ...OPERATOR_BASELINE, HOME: join(fx.root, 'operator-home') });
|
|
||||||
|
|
||||||
// The ledger records env as present names only, by design. This checks the design holds for
|
|
||||||
// values as well as for the credential file it was written to protect.
|
|
||||||
const ledger = readFileSync(fx.ledger, 'utf8');
|
|
||||||
for (const [name, value] of Object.entries(OPERATOR_ONLY_ENV)) {
|
|
||||||
expect(ledger, `ledger leaked the value of ${name}`).not.toContain(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
|
||||||
import {
|
|
||||||
chmodSync,
|
|
||||||
copyFileSync,
|
|
||||||
existsSync,
|
|
||||||
mkdtempSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
writeFileSync,
|
|
||||||
symlinkSync,
|
|
||||||
rmSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -19,8 +8,6 @@ import {
|
|||||||
enumerateSkillDirs,
|
enumerateSkillDirs,
|
||||||
piForceSkillNames,
|
piForceSkillNames,
|
||||||
registerRuntimeLaunchers,
|
registerRuntimeLaunchers,
|
||||||
checkSequentialThinking,
|
|
||||||
resolveExecutableFromPath,
|
|
||||||
type RuntimeLaunchHandler,
|
type RuntimeLaunchHandler,
|
||||||
type ClaudexLaunchHandler,
|
type ClaudexLaunchHandler,
|
||||||
} from './launch.js';
|
} from './launch.js';
|
||||||
@@ -99,269 +86,6 @@ describe('registerRuntimeLaunchers — non-yolo subcommands', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('checkSequentialThinking', () => {
|
|
||||||
it('runs the real fleet launch preflight against the injected seat, not HOME', () => {
|
|
||||||
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
|
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
|
||||||
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
|
||||||
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
|
||||||
try {
|
|
||||||
expect(
|
|
||||||
JSON.parse(
|
|
||||||
readFileSync(
|
|
||||||
join(process.cwd(), 'framework', 'runtime', 'claude', 'settings.json'),
|
|
||||||
'utf8',
|
|
||||||
),
|
|
||||||
).mcpServers['sequential-thinking'],
|
|
||||||
).toEqual({
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
|
||||||
});
|
|
||||||
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
|
||||||
copyFileSync(
|
|
||||||
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
|
||||||
checker,
|
|
||||||
);
|
|
||||||
mkdirSync(join(agentDir, '.claude'), { recursive: true });
|
|
||||||
writeFileSync(
|
|
||||||
join(agentDir, '.claude', '.claude.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
mcpServers: {
|
|
||||||
'sequential-thinking': {
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
vi.stubEnv('HOME', home);
|
|
||||||
expect(() =>
|
|
||||||
checkSequentialThinking('claude', { agentDir, mosaicHome: installed }),
|
|
||||||
).not.toThrow();
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
rmSync(home, { recursive: true, force: true });
|
|
||||||
rmSync(agentDir, { recursive: true, force: true });
|
|
||||||
rmSync(installed, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails the real fleet launch preflight when only operator HOME is seeded', () => {
|
|
||||||
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
|
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
|
||||||
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
|
||||||
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
|
||||||
const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
|
||||||
try {
|
|
||||||
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
|
||||||
copyFileSync(
|
|
||||||
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
|
||||||
checker,
|
|
||||||
);
|
|
||||||
writeFileSync(
|
|
||||||
join(home, '.claude.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
mcpServers: {
|
|
||||||
'sequential-thinking': {
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubEnv('HOME', home);
|
|
||||||
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
|
|
||||||
'process.exit called',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
exit.mockRestore();
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
rmSync(home, { recursive: true, force: true });
|
|
||||||
rmSync(agentDir, { recursive: true, force: true });
|
|
||||||
rmSync(installed, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes with a seeded seat even when operator HOME has no MCP configuration', () => {
|
|
||||||
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
|
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
|
||||||
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
|
||||||
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
|
||||||
try {
|
|
||||||
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
|
||||||
copyFileSync(
|
|
||||||
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
|
||||||
checker,
|
|
||||||
);
|
|
||||||
mkdirSync(join(agentDir, '.claude'), { recursive: true });
|
|
||||||
writeFileSync(
|
|
||||||
join(agentDir, '.claude', '.claude.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
mcpServers: {
|
|
||||||
'sequential-thinking': {
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
vi.stubEnv('MOSAIC_HOME', installed);
|
|
||||||
vi.stubEnv('HOME', home);
|
|
||||||
expect(() =>
|
|
||||||
checkSequentialThinking('claude', { agentDir, mosaicHome: installed }),
|
|
||||||
).not.toThrow();
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
rmSync(home, { recursive: true, force: true });
|
|
||||||
rmSync(agentDir, { recursive: true, force: true });
|
|
||||||
rmSync(installed, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('repairs a legacy seat config in place without using operator HOME', () => {
|
|
||||||
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
|
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
|
||||||
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
|
||||||
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
|
||||||
const bin = join(installed, 'bin');
|
|
||||||
try {
|
|
||||||
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
|
||||||
mkdirSync(join(agentDir, '.claude'), { recursive: true });
|
|
||||||
mkdirSync(bin, { recursive: true });
|
|
||||||
copyFileSync(
|
|
||||||
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
|
||||||
checker,
|
|
||||||
);
|
|
||||||
for (const name of ['node', 'npx']) {
|
|
||||||
writeFileSync(join(bin, name), '#!/usr/bin/env bash\nexit 0\n');
|
|
||||||
chmodSync(join(bin, name), 0o755);
|
|
||||||
}
|
|
||||||
writeFileSync(
|
|
||||||
join(agentDir, '.claude', '.claude.json'),
|
|
||||||
JSON.stringify({ hasCompletedOnboarding: true, theme: 'dark' }),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` };
|
|
||||||
expect(
|
|
||||||
spawnSync(
|
|
||||||
checker,
|
|
||||||
[
|
|
||||||
'--runtime',
|
|
||||||
'claude',
|
|
||||||
'--claude-config-dir',
|
|
||||||
join(agentDir, '.claude'),
|
|
||||||
'--python-bin',
|
|
||||||
'/usr/bin/python3',
|
|
||||||
'--node-bin',
|
|
||||||
'/usr/bin/node',
|
|
||||||
'--npx-bin',
|
|
||||||
'/usr/bin/npx',
|
|
||||||
'--timeout-bin',
|
|
||||||
'/usr/bin/timeout',
|
|
||||||
],
|
|
||||||
{
|
|
||||||
env,
|
|
||||||
},
|
|
||||||
).status,
|
|
||||||
).toBe(0);
|
|
||||||
expect(
|
|
||||||
spawnSync(
|
|
||||||
checker,
|
|
||||||
[
|
|
||||||
'--check',
|
|
||||||
'--runtime',
|
|
||||||
'claude',
|
|
||||||
'--claude-config-dir',
|
|
||||||
join(agentDir, '.claude'),
|
|
||||||
'--python-bin',
|
|
||||||
'/usr/bin/python3',
|
|
||||||
'--node-bin',
|
|
||||||
'/usr/bin/node',
|
|
||||||
'--npx-bin',
|
|
||||||
'/usr/bin/npx',
|
|
||||||
'--timeout-bin',
|
|
||||||
'/usr/bin/timeout',
|
|
||||||
],
|
|
||||||
{ env },
|
|
||||||
).status,
|
|
||||||
).toBe(0);
|
|
||||||
expect(
|
|
||||||
JSON.parse(readFileSync(join(agentDir, '.claude', '.claude.json'), 'utf8')),
|
|
||||||
).toMatchObject({
|
|
||||||
hasCompletedOnboarding: true,
|
|
||||||
theme: 'dark',
|
|
||||||
mcpServers: { 'sequential-thinking': { command: 'npx' } },
|
|
||||||
});
|
|
||||||
expect(existsSync(join(home, '.claude.json'))).toBe(false);
|
|
||||||
} finally {
|
|
||||||
rmSync(home, { recursive: true, force: true });
|
|
||||||
rmSync(agentDir, { recursive: true, force: true });
|
|
||||||
rmSync(installed, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a group-writable installed helper root', () => {
|
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
|
||||||
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
|
||||||
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
|
||||||
try {
|
|
||||||
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
|
||||||
copyFileSync(
|
|
||||||
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
|
||||||
checker,
|
|
||||||
);
|
|
||||||
chmodSync(installed, 0o770);
|
|
||||||
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
|
|
||||||
/not a trusted installed file/,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
chmodSync(installed, 0o700);
|
|
||||||
rmSync(agentDir, { recursive: true, force: true });
|
|
||||||
rmSync(installed, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses an empty seat even when operator HOME is configured', () => {
|
|
||||||
const home = mkdtempSync(join(tmpdir(), 'mosaic-seq-home-'));
|
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-seq-seat-'));
|
|
||||||
const installed = mkdtempSync(join(tmpdir(), 'mosaic-seq-installed-'));
|
|
||||||
const checker = join(installed, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
|
||||||
const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
|
||||||
try {
|
|
||||||
mkdirSync(join(installed, 'tools', '_scripts'), { recursive: true });
|
|
||||||
copyFileSync(
|
|
||||||
join(process.cwd(), 'framework', 'tools', '_scripts', 'mosaic-ensure-sequential-thinking'),
|
|
||||||
checker,
|
|
||||||
);
|
|
||||||
writeFileSync(
|
|
||||||
join(home, '.claude.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
mcpServers: {
|
|
||||||
'sequential-thinking': {
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubEnv('MOSAIC_HOME', installed);
|
|
||||||
vi.stubEnv('HOME', home);
|
|
||||||
expect(() => checkSequentialThinking('claude', { agentDir, mosaicHome: installed })).toThrow(
|
|
||||||
'process.exit called',
|
|
||||||
);
|
|
||||||
expect(exit).toHaveBeenCalledWith(1);
|
|
||||||
} finally {
|
|
||||||
exit.mockRestore();
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
rmSync(home, { recursive: true, force: true });
|
|
||||||
rmSync(agentDir, { recursive: true, force: true });
|
|
||||||
rmSync(installed, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildPiSkillArgs', () => {
|
describe('buildPiSkillArgs', () => {
|
||||||
it('disables auto-discovery but force-loads fleet-critical skills by default', () => {
|
it('disables auto-discovery but force-loads fleet-critical skills by default', () => {
|
||||||
expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([
|
expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([
|
||||||
@@ -625,146 +349,3 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
|
|||||||
expect(mockExit).not.toHaveBeenCalled();
|
expect(mockExit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Executable resolution for fleet launches (AMD1213-D, D3/D6).
|
|
||||||
*
|
|
||||||
* The defect these cover: the launcher proved a runtime existed by running ambient
|
|
||||||
* `which`, then spawned the bare name and let the OS resolve it a second time against an
|
|
||||||
* ambient PATH. A directory prepended to PATH satisfied the probe and then supplied the
|
|
||||||
* binary that actually ran, so the check could pass without ever reading seat state.
|
|
||||||
*
|
|
||||||
* Every case below was run against the pre-change resolution first. The shim case is the
|
|
||||||
* one that matters -- under `which` + bare-name spawn it passes, because that is exactly
|
|
||||||
* the behaviour being removed.
|
|
||||||
*/
|
|
||||||
describe('resolveExecutableFromPath', () => {
|
|
||||||
let dir: string;
|
|
||||||
|
|
||||||
const bin = (root: string, name: string, mode = 0o755): string => {
|
|
||||||
const p = join(root, name);
|
|
||||||
writeFileSync(p, '#!/bin/sh\nexit 0\n');
|
|
||||||
chmodSync(p, mode);
|
|
||||||
return p;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
dir = mkdtempSync(join(tmpdir(), 'mosaic-exec-resolve-'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves a safe executable and reports its real path and identity', () => {
|
|
||||||
const safe = join(dir, 'safe');
|
|
||||||
mkdirSync(safe, { mode: 0o755 });
|
|
||||||
const target = bin(safe, 'codex');
|
|
||||||
|
|
||||||
const resolved = resolveExecutableFromPath('codex', safe);
|
|
||||||
|
|
||||||
expect(resolved.path).toBe(target);
|
|
||||||
expect(resolved.ino).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a world-writable binary planted on PATH', () => {
|
|
||||||
// The shim case. `which` reports this happily and a bare-name spawn runs it.
|
|
||||||
const shim = join(dir, 'shim');
|
|
||||||
mkdirSync(shim, { mode: 0o755 });
|
|
||||||
bin(shim, 'codex', 0o777);
|
|
||||||
|
|
||||||
expect(() => resolveExecutableFromPath('codex', shim)).toThrow(/writable by group or other/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a safe binary reached through a world-writable directory', () => {
|
|
||||||
// The binary itself is fine; anyone can swap it for one that is not.
|
|
||||||
const open = join(dir, 'open');
|
|
||||||
mkdirSync(open, { mode: 0o755 });
|
|
||||||
bin(open, 'codex');
|
|
||||||
// chmod after mkdir: the mode argument is masked by the process umask, so a
|
|
||||||
// directory created as 0o777 is really 0o755 and the case tests nothing.
|
|
||||||
chmodSync(open, 0o777);
|
|
||||||
|
|
||||||
expect(() => resolveExecutableFromPath('codex', open)).toThrow(/writable directory/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not fall through to a later PATH entry when the first match is unsafe', () => {
|
|
||||||
// Falling through would let a planted unsafe binary silently downgrade the search to
|
|
||||||
// whatever came after it, inverting the precedence PATH exists to express.
|
|
||||||
const shim = join(dir, 'first');
|
|
||||||
const good = join(dir, 'second');
|
|
||||||
mkdirSync(shim, { mode: 0o755 });
|
|
||||||
mkdirSync(good, { mode: 0o755 });
|
|
||||||
bin(shim, 'codex', 0o777);
|
|
||||||
const safeTarget = bin(good, 'codex');
|
|
||||||
|
|
||||||
let resolvedPath: string | undefined;
|
|
||||||
try {
|
|
||||||
resolvedPath = resolveExecutableFromPath('codex', `${shim}:${good}`).path;
|
|
||||||
} catch {
|
|
||||||
resolvedPath = undefined;
|
|
||||||
}
|
|
||||||
expect(resolvedPath).not.toBe(safeTarget);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores a relative PATH entry', () => {
|
|
||||||
// A relative entry resolves against the current directory, so what it names depends
|
|
||||||
// on where the launcher happened to be started.
|
|
||||||
expect(() => resolveExecutableFromPath('codex', '.:relative/bin')).toThrow(/not found/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('follows a symlink and validates the real file behind it', () => {
|
|
||||||
const safe = join(dir, 'real');
|
|
||||||
const linkDir = join(dir, 'links');
|
|
||||||
mkdirSync(safe, { mode: 0o755 });
|
|
||||||
mkdirSync(linkDir, { mode: 0o755 });
|
|
||||||
const target = bin(safe, 'codex-real');
|
|
||||||
symlinkSync(target, join(linkDir, 'codex'));
|
|
||||||
|
|
||||||
expect(resolveExecutableFromPath('codex', linkDir).path).toBe(target);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a symlink whose real target is unsafe', () => {
|
|
||||||
const open = join(dir, 'openreal');
|
|
||||||
const linkDir = join(dir, 'links2');
|
|
||||||
mkdirSync(open, { mode: 0o755 });
|
|
||||||
mkdirSync(linkDir, { mode: 0o755 });
|
|
||||||
const target = bin(open, 'codex-real', 0o777);
|
|
||||||
symlinkSync(target, join(linkDir, 'codex'));
|
|
||||||
|
|
||||||
expect(() => resolveExecutableFromPath('codex', linkDir)).toThrow(/writable by group or other/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a non-executable file', () => {
|
|
||||||
const safe = join(dir, 'noexec');
|
|
||||||
mkdirSync(safe, { mode: 0o755 });
|
|
||||||
bin(safe, 'codex', 0o644);
|
|
||||||
|
|
||||||
expect(() => resolveExecutableFromPath('codex', safe)).toThrow(/not executable/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a directory that merely shares the name', () => {
|
|
||||||
const safe = join(dir, 'dirname');
|
|
||||||
mkdirSync(join(safe, 'codex'), { recursive: true, mode: 0o755 });
|
|
||||||
|
|
||||||
expect(() => resolveExecutableFromPath('codex', safe)).toThrow(/not a regular file/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a name that is a path rather than a bare command', () => {
|
|
||||||
expect(() => resolveExecutableFromPath('../evil', dir)).toThrow(/bare command name/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses when no PATH was declared', () => {
|
|
||||||
expect(() => resolveExecutableFromPath('codex', undefined)).toThrow(/no PATH was declared/);
|
|
||||||
expect(() => resolveExecutableFromPath('codex', '')).toThrow(/no PATH was declared/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports not-found rather than resolving something else', () => {
|
|
||||||
const empty = join(dir, 'empty');
|
|
||||||
mkdirSync(empty, { mode: 0o755 });
|
|
||||||
|
|
||||||
expect(() => resolveExecutableFromPath('codex', empty)).toThrow(
|
|
||||||
/not found on the declared PATH/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
import { execFileSync, execSync, spawnSync } from 'node:child_process';
|
||||||
import {
|
import {
|
||||||
existsSync,
|
existsSync,
|
||||||
lstatSync,
|
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
@@ -20,14 +19,14 @@ import {
|
|||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { homedir, hostname } from 'node:os';
|
import { homedir, hostname } from 'node:os';
|
||||||
import { join, dirname, relative, resolve, sep, delimiter, isAbsolute } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import {
|
import {
|
||||||
buildResolvedFleetCommsBlock,
|
buildResolvedFleetCommsBlock,
|
||||||
renderToolsContractStatus,
|
renderToolsContractStatus,
|
||||||
resolveFleetIdentity,
|
resolveFleetIdentity,
|
||||||
} from '../fleet/comms-onboarding.js';
|
} from '../fleet/comms-onboarding.js';
|
||||||
import { assertNoSymlinkAncestors, readRegularFileSecure } from '../fleet/secure-file.js';
|
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||||
@@ -36,14 +35,7 @@ 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;
|
||||||
|
|
||||||
export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||||
|
|
||||||
/** Fleet context for the single harness-home resolution seam. */
|
|
||||||
export interface FleetHarnessContext {
|
|
||||||
readonly agentDir: string;
|
|
||||||
/** Active installed Mosaic root for fleet-specific helper resolution. */
|
|
||||||
readonly mosaicHome?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
const RUNTIME_LABELS: Record<RuntimeName, string> = {
|
||||||
claude: 'Claude Code',
|
claude: 'Claude Code',
|
||||||
@@ -72,19 +64,19 @@ const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
|
|||||||
opencode: 'XDG_CONFIG_HOME',
|
opencode: 'XDG_CONFIG_HOME',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Dedicated runtime home, optionally scoped to a user fleet agent. */
|
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
|
||||||
export function harnessHome(runtime: RuntimeName, fleet?: FleetHarnessContext): string {
|
function harnessHome(runtime: RuntimeName): string {
|
||||||
return join(fleet?.agentDir ?? MOSAIC_HOME, `.${runtime}`);
|
return join(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, fleet?: FleetHarnessContext): Record<string, string> {
|
function harnessEnv(runtime: RuntimeName): 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, fleet);
|
const home = harnessHome(runtime);
|
||||||
mkdirSync(home, { recursive: true });
|
mkdirSync(home, { recursive: true });
|
||||||
return { [key]: home };
|
return { [key]: home };
|
||||||
}
|
}
|
||||||
@@ -140,18 +132,15 @@ function sha256Of(value: string | Buffer): string {
|
|||||||
* so an unexpected digest here is a mechanically detectable red flag rather than
|
* so an unexpected digest here is a mechanically detectable red flag rather than
|
||||||
* a matter of judgement.
|
* a matter of judgement.
|
||||||
*/
|
*/
|
||||||
function normativeFragmentDigests(
|
function normativeFragmentDigests(runtime: RuntimeName): NormativeFragmentDigest[] {
|
||||||
runtime: RuntimeName,
|
|
||||||
mosaicHome: string = MOSAIC_HOME,
|
|
||||||
): NormativeFragmentDigest[] {
|
|
||||||
const candidates: Array<[string, string]> = [
|
const candidates: Array<[string, string]> = [
|
||||||
['CONSTITUTION.md', join(mosaicHome, 'CONSTITUTION.md')],
|
['CONSTITUTION.md', join(MOSAIC_HOME, 'CONSTITUTION.md')],
|
||||||
['AGENTS.md', join(mosaicHome, 'AGENTS.md')],
|
['AGENTS.md', join(MOSAIC_HOME, 'AGENTS.md')],
|
||||||
['SOUL.md', join(mosaicHome, 'SOUL.md')],
|
['SOUL.md', join(MOSAIC_HOME, 'SOUL.md')],
|
||||||
['USER.md', join(mosaicHome, 'USER.md')],
|
['USER.md', join(MOSAIC_HOME, 'USER.md')],
|
||||||
['STANDARDS.md', join(mosaicHome, 'STANDARDS.md')],
|
['STANDARDS.md', join(MOSAIC_HOME, 'STANDARDS.md')],
|
||||||
['TOOLS.md', join(mosaicHome, 'TOOLS.md')],
|
['TOOLS.md', join(MOSAIC_HOME, 'TOOLS.md')],
|
||||||
[`runtime/${runtime}/RUNTIME.md`, join(mosaicHome, 'runtime', runtime, 'RUNTIME.md')],
|
[`runtime/${runtime}/RUNTIME.md`, join(MOSAIC_HOME, 'runtime', runtime, 'RUNTIME.md')],
|
||||||
];
|
];
|
||||||
return candidates.map(([sourceId, path]) => {
|
return candidates.map(([sourceId, path]) => {
|
||||||
try {
|
try {
|
||||||
@@ -173,18 +162,9 @@ function redactArgv(argv: string[]): string[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recordLaunch(
|
function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): void {
|
||||||
runtime: RuntimeName,
|
|
||||||
cliArgs: string[],
|
|
||||||
yolo: boolean,
|
|
||||||
fleet?: FleetHarnessContext,
|
|
||||||
launchEnv: NodeJS.ProcessEnv = process.env,
|
|
||||||
): void {
|
|
||||||
try {
|
try {
|
||||||
const ledgerDir = fleet?.mosaicHome
|
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
||||||
? join(fleet.mosaicHome, 'fleet', 'run', 'sessions')
|
|
||||||
: LAUNCH_LEDGER_DIR;
|
|
||||||
mkdirSync(ledgerDir, { 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
|
||||||
// propagates through every `...process.env` / `...baseEnv` spread below.
|
// propagates through every `...process.env` / `...baseEnv` spread below.
|
||||||
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
const launchId = `${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
||||||
@@ -200,17 +180,17 @@ function recordLaunch(
|
|||||||
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, fleet),
|
config_home: harnessHome(runtime),
|
||||||
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, fleet?.mosaicHome),
|
normative_fragments: normativeFragmentDigests(runtime),
|
||||||
// names only — values are never recorded
|
// names only — values are never recorded
|
||||||
mosaic_env_present: Object.keys(launchEnv)
|
mosaic_env_present: Object.keys(process.env)
|
||||||
.filter((k) => k.startsWith('MOSAIC_'))
|
.filter((k) => k.startsWith('MOSAIC_'))
|
||||||
.sort(),
|
.sort(),
|
||||||
};
|
};
|
||||||
appendFileSync(join(ledgerDir, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
appendFileSync(join(LAUNCH_LEDGER_DIR, 'events.ndjson'), `${JSON.stringify(record)}\n`, {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -250,125 +230,6 @@ function checkRuntime(cmd: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An executable located once and pinned by the identity it had when validated. */
|
|
||||||
interface ResolvedExecutable {
|
|
||||||
readonly path: string;
|
|
||||||
readonly dev: number | bigint;
|
|
||||||
readonly ino: number | bigint;
|
|
||||||
}
|
|
||||||
|
|
||||||
function executableRefusal(name: string, detail: string): Error {
|
|
||||||
return new Error(`refusing to launch '${name}': ${detail}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reject a directory whose contents someone else could swap under us.
|
|
||||||
*
|
|
||||||
* Group- or world-writable is the disqualifier, with the /tmp exception: a sticky
|
|
||||||
* directory is writable by design but only its owner may replace its entries, so it
|
|
||||||
* cannot be used to shadow one.
|
|
||||||
*/
|
|
||||||
function assertSafeAncestry(path: string, name: string, owner: number | undefined): void {
|
|
||||||
let cursor = dirname(path);
|
|
||||||
for (;;) {
|
|
||||||
const info = lstatSync(cursor);
|
|
||||||
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
||||||
throw executableRefusal(name, `path component is not a real directory: ${cursor}`);
|
|
||||||
}
|
|
||||||
if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0) {
|
|
||||||
throw executableRefusal(name, `writable directory on the resolved path: ${cursor}`);
|
|
||||||
}
|
|
||||||
if (owner !== undefined && info.uid !== owner && info.uid !== 0) {
|
|
||||||
throw executableRefusal(
|
|
||||||
name,
|
|
||||||
`directory on the resolved path has a foreign owner: ${cursor}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const parent = dirname(cursor);
|
|
||||||
if (parent === cursor) return;
|
|
||||||
cursor = parent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find one executable named `name`, searching only `searchPath`, and validate the object
|
|
||||||
* that search lands on.
|
|
||||||
*
|
|
||||||
* This exists because `which` answered a different question than the one the launcher
|
|
||||||
* needed. `which` reported that *something* by that name was reachable; the launcher then
|
|
||||||
* spawned the bare name and let the OS resolve it a second time, against an ambient PATH,
|
|
||||||
* at a later moment. Two independent resolutions of an attacker-influenced name, with a
|
|
||||||
* gap in between, is not a check -- a directory prepended to PATH satisfied the probe and
|
|
||||||
* then supplied the thing that actually ran. Resolving once here and executing the exact
|
|
||||||
* path returned is the whole point; callers must not go back to the name.
|
|
||||||
*
|
|
||||||
* Rules worth stating because each one is a hole if dropped:
|
|
||||||
*
|
|
||||||
* * A relative PATH entry is skipped. It resolves against the current directory, so
|
|
||||||
* what it names depends on where the launcher happened to be started.
|
|
||||||
* * The FIRST name match decides the outcome, and an unsafe first match is a refusal
|
|
||||||
* rather than a reason to keep looking. Falling through to a later entry would let a
|
|
||||||
* planted unsafe binary silently downgrade the search to whatever came after it,
|
|
||||||
* which inverts the precedence PATH is supposed to express.
|
|
||||||
* * A symlink is followed, and the real file it lands on is what gets validated and
|
|
||||||
* executed. Validating the link and executing the name would repeat the original bug
|
|
||||||
* one level down.
|
|
||||||
*/
|
|
||||||
export function resolveExecutableFromPath(
|
|
||||||
name: string,
|
|
||||||
searchPath: string | undefined,
|
|
||||||
): ResolvedExecutable {
|
|
||||||
if (name.includes('/')) {
|
|
||||||
throw executableRefusal(name, 'expected a bare command name, not a path');
|
|
||||||
}
|
|
||||||
if (searchPath === undefined || searchPath === '') {
|
|
||||||
throw executableRefusal(name, 'no PATH was declared for the launch');
|
|
||||||
}
|
|
||||||
const owner = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
||||||
|
|
||||||
for (const entry of searchPath.split(delimiter)) {
|
|
||||||
if (entry === '' || !isAbsolute(entry)) continue;
|
|
||||||
const candidate = join(entry, name);
|
|
||||||
if (!existsSync(candidate)) continue;
|
|
||||||
|
|
||||||
// First match wins, for good or ill. Everything below either returns or throws.
|
|
||||||
const real = realpathSync(candidate);
|
|
||||||
const info = lstatSync(real);
|
|
||||||
if (!info.isFile()) {
|
|
||||||
throw executableRefusal(name, `${real} is not a regular file`);
|
|
||||||
}
|
|
||||||
if ((info.mode & 0o111) === 0) {
|
|
||||||
throw executableRefusal(name, `${real} is not executable`);
|
|
||||||
}
|
|
||||||
if ((info.mode & 0o022) !== 0) {
|
|
||||||
throw executableRefusal(name, `${real} is writable by group or other`);
|
|
||||||
}
|
|
||||||
if (owner !== undefined && info.uid !== owner && info.uid !== 0) {
|
|
||||||
throw executableRefusal(name, `${real} is owned by neither the launching user nor root`);
|
|
||||||
}
|
|
||||||
assertSafeAncestry(real, name, owner);
|
|
||||||
return { path: real, dev: info.dev, ino: info.ino };
|
|
||||||
}
|
|
||||||
throw executableRefusal(name, `not found on the declared PATH`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-confirm, immediately before spawning, that the path still names the object that was
|
|
||||||
* validated.
|
|
||||||
*
|
|
||||||
* This narrows the window between validation and exec; it does not close it. Closing it
|
|
||||||
* would mean executing a held descriptor, and there is no portable way to exec by
|
|
||||||
* descriptor from Node. The residual is a same-UID replacement landing inside the
|
|
||||||
* remaining window, which is the same accepted boundary already documented for the fleet
|
|
||||||
* helper. Stated rather than engineered around, so nobody reads this as a proof.
|
|
||||||
*/
|
|
||||||
function assertUnchangedSinceValidation(executable: ResolvedExecutable, name: string): void {
|
|
||||||
const now = lstatSync(executable.path);
|
|
||||||
if (now.dev !== executable.dev || now.ino !== executable.ino) {
|
|
||||||
throw executableRefusal(name, `${executable.path} was replaced after it was validated`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkSoul(): void {
|
function checkSoul(): void {
|
||||||
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
||||||
if (!existsSync(soulPath)) {
|
if (!existsSync(soulPath)) {
|
||||||
@@ -401,9 +262,9 @@ interface SettingsAudit {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function auditClaudeSettings(fleet?: FleetHarnessContext): SettingsAudit {
|
function auditClaudeSettings(): SettingsAudit {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const settingsPath = join(harnessHome('claude', fleet), 'settings.json');
|
const settingsPath = join(harnessHome('claude'), 'settings.json');
|
||||||
const settings = readJson(settingsPath);
|
const settings = readJson(settingsPath);
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
@@ -471,141 +332,13 @@ function printSettingsWarnings(audit: SettingsAudit): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrustedCapability {
|
function checkSequentialThinking(runtime: string): void {
|
||||||
readonly path: string;
|
|
||||||
readonly content: Buffer;
|
|
||||||
readonly dev: number | bigint;
|
|
||||||
readonly ino: number | bigint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The fleet helper accepts capabilities only from root-owned /usr/bin. */
|
|
||||||
function trustedCapability(name: string): TrustedCapability {
|
|
||||||
const candidate = join('/usr/bin', name);
|
|
||||||
let path: string;
|
|
||||||
try {
|
|
||||||
path = realpathSync(candidate);
|
|
||||||
if (!path.startsWith('/usr/')) throw new Error('resolved outside /usr');
|
|
||||||
const snapshot = readRegularFileSecure(path, {
|
|
||||||
root: '/',
|
|
||||||
executable: true,
|
|
||||||
maxBytes: 64 * 1024 * 1024,
|
|
||||||
});
|
|
||||||
const info = lstatSync(path);
|
|
||||||
if ((info.mode & 0o022) !== 0 || info.uid !== 0)
|
|
||||||
throw new Error('unsafe capability owner or mode');
|
|
||||||
return { path, content: snapshot.content, dev: snapshot.dev, ino: snapshot.ino };
|
|
||||||
} catch (error: unknown) {
|
|
||||||
throw new Error(
|
|
||||||
`required trusted fleet capability is unavailable: ${name}: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function trustedFleetHelper(mosaicHome: string): TrustedCapability {
|
|
||||||
const root = resolve(mosaicHome);
|
|
||||||
const checker = join(root, 'tools', '_scripts', 'mosaic-ensure-sequential-thinking');
|
|
||||||
try {
|
|
||||||
assertNoSymlinkAncestors(checker);
|
|
||||||
const owner = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
||||||
let cursor = root;
|
|
||||||
for (const component of relative(root, checker).split(sep).filter(Boolean)) {
|
|
||||||
const info = lstatSync(cursor);
|
|
||||||
if (
|
|
||||||
!info.isDirectory() ||
|
|
||||||
info.isSymbolicLink() ||
|
|
||||||
(info.mode & 0o022) !== 0 ||
|
|
||||||
(owner !== undefined && info.uid !== owner && info.uid !== 0)
|
|
||||||
) {
|
|
||||||
throw new Error('helper directory has unsafe type, owner, or permissions');
|
|
||||||
}
|
|
||||||
cursor = join(cursor, component);
|
|
||||||
}
|
|
||||||
const helperInfo = lstatSync(checker);
|
|
||||||
if (
|
|
||||||
!helperInfo.isFile() ||
|
|
||||||
helperInfo.isSymbolicLink() ||
|
|
||||||
(helperInfo.mode & 0o022) !== 0 ||
|
|
||||||
(helperInfo.mode & 0o111) === 0 ||
|
|
||||||
(owner !== undefined && helperInfo.uid !== owner && helperInfo.uid !== 0)
|
|
||||||
) {
|
|
||||||
throw new Error('helper has unsafe type, owner, or permissions');
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
throw new Error(
|
|
||||||
`fleet sequential-thinking helper is not a trusted installed file under ${root}: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const snapshot = readRegularFileSecure(checker, {
|
|
||||||
root,
|
|
||||||
executable: true,
|
|
||||||
maxBytes: 1024 * 1024,
|
|
||||||
});
|
|
||||||
return { path: checker, content: snapshot.content, dev: snapshot.dev, ino: snapshot.ino };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function checkSequentialThinking(runtime: RuntimeName, fleet?: FleetHarnessContext): void {
|
|
||||||
// Fleet launch must use the active --mosaic-home installation. Non-fleet
|
|
||||||
// launches retain the package/deployed helper resolver.
|
|
||||||
if (!fleet?.mosaicHome) {
|
|
||||||
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||||
if (!existsSync(checker)) return;
|
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
||||||
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
|
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
|
||||||
if (result.status !== 0) process.exit(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const helper = trustedFleetHelper(fleet.mosaicHome);
|
|
||||||
const bash = trustedCapability('bash');
|
|
||||||
const python = trustedCapability('python3');
|
|
||||||
const node = trustedCapability('node');
|
|
||||||
const npx = trustedCapability('npx');
|
|
||||||
const timeout = trustedCapability('timeout');
|
|
||||||
const fleetClaudeConfig =
|
|
||||||
runtime === 'claude' && fleet ? harnessHome('claude', fleet) : undefined;
|
|
||||||
const fleetCodexHome = runtime === 'codex' && fleet ? harnessHome('codex', fleet) : undefined;
|
|
||||||
const fleetOpenCodeHome =
|
|
||||||
runtime === 'opencode' && fleet ? harnessHome('opencode', fleet) : undefined;
|
|
||||||
const result = spawnSync(
|
|
||||||
bash.path,
|
|
||||||
[
|
|
||||||
'-s',
|
|
||||||
'--',
|
|
||||||
'--check',
|
|
||||||
'--runtime',
|
|
||||||
runtime,
|
|
||||||
'--python-bin',
|
|
||||||
python.path,
|
|
||||||
'--node-bin',
|
|
||||||
node.path,
|
|
||||||
'--npx-bin',
|
|
||||||
npx.path,
|
|
||||||
'--timeout-bin',
|
|
||||||
timeout.path,
|
|
||||||
...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]),
|
|
||||||
],
|
|
||||||
{
|
|
||||||
input: helper.content,
|
|
||||||
stdio: ['pipe', 'ignore', 'ignore'],
|
|
||||||
env: {
|
|
||||||
HOME: fleetClaudeConfig ?? join(fleet.agentDir, '.mosaic-seq-home'),
|
|
||||||
LANG: 'C.UTF-8',
|
|
||||||
...(process.env['MOSAIC_SEQ_CHECK_WARM'] === undefined
|
|
||||||
? {}
|
|
||||||
: { MOSAIC_SEQ_CHECK_WARM: process.env['MOSAIC_SEQ_CHECK_WARM'] }),
|
|
||||||
...(process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] === undefined
|
|
||||||
? {}
|
|
||||||
: { MOSAIC_SEQ_WARM_TIMEOUT_SEC: process.env['MOSAIC_SEQ_WARM_TIMEOUT_SEC'] }),
|
|
||||||
...(fleetCodexHome === undefined ? {} : { CODEX_HOME: fleetCodexHome }),
|
|
||||||
...(fleetOpenCodeHome === undefined ? {} : { XDG_CONFIG_HOME: fleetOpenCodeHome }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
|
console.error('[mosaic] ERROR: sequential-thinking MCP is required but not configured.');
|
||||||
const repairArgs =
|
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}`);
|
||||||
fleetClaudeConfig === undefined
|
|
||||||
? ''
|
|
||||||
: ` --claude-config-dir ${fleetClaudeConfig} --python-bin ${python.path} --node-bin ${node.path} --npx-bin ${npx.path} --timeout-bin ${timeout.path}`;
|
|
||||||
console.error(`[mosaic] Fix: ${helper.path} --runtime ${runtime}${repairArgs}`);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -750,11 +483,7 @@ 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(
|
export function composeContract(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string {
|
||||||
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'),
|
||||||
@@ -811,13 +540,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, env['MOSAIC_AGENT_NAME']);
|
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.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 && env['MOSAIC_AGENT_CLASS']?.trim()) {
|
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
|
||||||
const ambientClass = canonicalizeRoleClass(env['MOSAIC_AGENT_CLASS']).canonicalClass;
|
const ambientClass = canonicalizeRoleClass(process.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.`,
|
||||||
@@ -854,13 +583,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 ?? env['MOSAIC_AGENT_CLASS'];
|
const personaClass = canonicalMember?.className ?? process.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
|
||||||
: env['MOSAIC_AGENT_TOOL_POLICY'];
|
: process.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);
|
||||||
|
|
||||||
@@ -884,8 +613,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, env: NodeJS.ProcessEnv = process.env): string {
|
function buildRuntimePrompt(runtime: RuntimeName): string {
|
||||||
return composeContract(runtime, MOSAIC_HOME, env);
|
return composeContract(runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Session lock ────────────────────────────────────────────────────────────
|
// ─── Session lock ────────────────────────────────────────────────────────────
|
||||||
@@ -966,12 +695,8 @@ function checkResumableSession(): void {
|
|||||||
|
|
||||||
// ─── Write config for runtimes that read from fixed paths ────────────────────
|
// ─── Write config for runtimes that read from fixed paths ────────────────────
|
||||||
|
|
||||||
function ensureRuntimeConfig(
|
function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
|
||||||
runtime: RuntimeName,
|
const prompt = buildRuntimePrompt(runtime);
|
||||||
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) {
|
||||||
@@ -1164,156 +889,34 @@ 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.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimeLaunchContext {
|
function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): never {
|
||||||
readonly mosaicHome?: string;
|
|
||||||
readonly fleet?: FleetHarnessContext;
|
|
||||||
readonly declaredEnv?: Readonly<Record<string, string>>;
|
|
||||||
/** Test seam: bypass only final runtime binary discovery. */
|
|
||||||
readonly runtimeCheck?: (runtime: RuntimeName) => void;
|
|
||||||
/** Test seam: receives the fully composed final runtime invocation. */
|
|
||||||
readonly finalExecutor?: (runtime: RuntimeName, args: string[], env: NodeJS.ProcessEnv) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locale for a composed launch.
|
|
||||||
*
|
|
||||||
* A seat that inherits the operator's locale behaves differently depending on who happened to
|
|
||||||
* start it: locale selects message language, collation, and number and date formatting, so the
|
|
||||||
* same runtime doing the same work emits different text. That is a reproducibility problem for
|
|
||||||
* the seat and a correctness problem for anything parsing what it prints.
|
|
||||||
*
|
|
||||||
* C.UTF-8 rather than C: both are unambiguous, but plain C is ASCII and would mangle non-ASCII
|
|
||||||
* output, so pinning it would trade one defect for another. A seat that genuinely needs a
|
|
||||||
* different locale declares LANG or LC_ALL in its profile, and the declared value wins.
|
|
||||||
*/
|
|
||||||
const COMPOSED_LAUNCH_LOCALE = 'C.UTF-8';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The environment a composed (fleet) launch hands its child.
|
|
||||||
*
|
|
||||||
* Built from an empty object rather than by subtracting from `process.env`, so the set of names
|
|
||||||
* that reach the child is a closed list that has to be edited deliberately. An allowlist fails
|
|
||||||
* safe as the operator's environment grows; a denylist silently passes every variable nobody
|
|
||||||
* thought of, which is where `BASH_ENV`, `PYTHONSTARTUP`, `NODE_OPTIONS` and `LD_PRELOAD` live --
|
|
||||||
* names that execute attacker-chosen code inside a process that was otherwise fully validated.
|
|
||||||
*
|
|
||||||
* Locale is fixed rather than inherited (above). Everything else here is inherited because the
|
|
||||||
* child needs the operator's actual value: PATH is resolved and validated separately before use,
|
|
||||||
* and HOME remains the operator's -- see the residual recorded in the AMD1213-D scratchpad.
|
|
||||||
*/
|
|
||||||
function minimalLaunchEnv(declared: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
|
|
||||||
const env: NodeJS.ProcessEnv = {
|
|
||||||
LANG: COMPOSED_LAUNCH_LOCALE,
|
|
||||||
LC_ALL: COMPOSED_LAUNCH_LOCALE,
|
|
||||||
};
|
|
||||||
for (const name of [
|
|
||||||
'PATH',
|
|
||||||
'HOME',
|
|
||||||
'USER',
|
|
||||||
'LOGNAME',
|
|
||||||
'SHELL',
|
|
||||||
'TERM',
|
|
||||||
'COLORTERM',
|
|
||||||
'TMPDIR',
|
|
||||||
'XDG_RUNTIME_DIR',
|
|
||||||
]) {
|
|
||||||
const value = process.env[name];
|
|
||||||
if (value !== undefined) env[name] = value;
|
|
||||||
}
|
|
||||||
return { ...env, ...declared };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The PATH the launched child will actually receive.
|
|
||||||
*
|
|
||||||
* Resolution has to consult this exact value and not `process.env.PATH`. If the declared
|
|
||||||
* environment overrides PATH, validating against the launcher's own PATH would check one
|
|
||||||
* set of directories and hand the child a different set -- a check answering a question
|
|
||||||
* nobody asked.
|
|
||||||
*/
|
|
||||||
function launchSearchPath(
|
|
||||||
declared: Readonly<Record<string, string>> | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
return declared?.['PATH'] ?? process.env['PATH'];
|
|
||||||
}
|
|
||||||
|
|
||||||
function launchRuntime(
|
|
||||||
runtime: RuntimeName,
|
|
||||||
args: string[],
|
|
||||||
yolo: boolean,
|
|
||||||
context: RuntimeLaunchContext = {},
|
|
||||||
): never {
|
|
||||||
const mosaicHome = context.mosaicHome ?? MOSAIC_HOME;
|
|
||||||
if (context.mosaicHome === undefined) {
|
|
||||||
checkMosaicHome();
|
checkMosaicHome();
|
||||||
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
||||||
checkSoul();
|
checkSoul();
|
||||||
} else {
|
|
||||||
if (!existsSync(mosaicHome)) throw new Error(`Mosaic home not found: ${mosaicHome}`);
|
|
||||||
checkFile(join(mosaicHome, 'AGENTS.md'), 'AGENTS.md');
|
|
||||||
if (!existsSync(join(mosaicHome, 'SOUL.md'))) {
|
|
||||||
throw new Error(`SOUL.md not found: ${mosaicHome}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A fleet launch resolves and validates the runtime binary here, once, and reuses that
|
|
||||||
// exact object below. `checkRuntime`'s ambient `which` stays on the operator path only:
|
|
||||||
// it proves reachability from the operator's own shell, which is the right question
|
|
||||||
// there and the wrong one for a seat. Kept in the same position in the sequence so a
|
|
||||||
// missing runtime still fails before the session lock is written.
|
|
||||||
let resolvedRuntime: ResolvedExecutable | undefined;
|
|
||||||
if (context.runtimeCheck) {
|
|
||||||
context.runtimeCheck(runtime);
|
|
||||||
} else if (context.fleet) {
|
|
||||||
try {
|
|
||||||
resolvedRuntime = resolveExecutableFromPath(runtime, launchSearchPath(context.declaredEnv));
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(`[mosaic] ERROR: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
checkRuntime(runtime);
|
checkRuntime(runtime);
|
||||||
}
|
|
||||||
|
|
||||||
// Pi doesn't need sequential-thinking (has native thinking levels)
|
// Pi doesn't need sequential-thinking (has native thinking levels)
|
||||||
if (runtime !== 'pi') {
|
if (runtime !== 'pi') {
|
||||||
checkSequentialThinking(runtime, context.fleet);
|
checkSequentialThinking(runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkResumableSession();
|
checkResumableSession();
|
||||||
|
|
||||||
const missionPrompt = context.mosaicHome === undefined ? getMissionPrompt() : '';
|
const missionPrompt = getMissionPrompt();
|
||||||
const hasMissionNoArgs = missionPrompt && args.length === 0;
|
const hasMissionNoArgs = missionPrompt && args.length === 0;
|
||||||
const label = RUNTIME_LABELS[runtime];
|
const label = RUNTIME_LABELS[runtime];
|
||||||
const modeStr = yolo ? ' in YOLO mode' : '';
|
const modeStr = yolo ? ' in YOLO mode' : '';
|
||||||
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(context.fleet);
|
const settingsAudit = auditClaudeSettings();
|
||||||
printSettingsWarnings(settingsAudit);
|
printSettingsWarnings(settingsAudit);
|
||||||
|
|
||||||
const prompt = composeContract('claude', mosaicHome, contractEnv);
|
const prompt = buildRuntimePrompt('claude');
|
||||||
const cliArgs: string[] = [];
|
const cliArgs: string[] = [];
|
||||||
cliArgs.push('--append-system-prompt', prompt);
|
cliArgs.push('--append-system-prompt', prompt);
|
||||||
if (hasMissionNoArgs) {
|
if (hasMissionNoArgs) {
|
||||||
@@ -1322,24 +925,13 @@ function launchRuntime(
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
|
recordLaunch('claude', cliArgs, yolo);
|
||||||
if (process.env['MOSAIC_LAUNCH_ID']) {
|
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||||
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
|
|
||||||
}
|
|
||||||
if (context.finalExecutor) {
|
|
||||||
context.finalExecutor('claude', cliArgs, launchEnv);
|
|
||||||
} else {
|
|
||||||
execLeaseGatedRuntime('claude', cliArgs, launchEnv, yolo, context.fleet);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'codex': {
|
case 'codex': {
|
||||||
ensureRuntimeConfig(
|
ensureRuntimeConfig('codex', join(harnessHome('codex'), 'instructions.md'));
|
||||||
'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);
|
||||||
@@ -1347,38 +939,22 @@ function launchRuntime(
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('codex', cliArgs, yolo, context.fleet, launchEnv);
|
recordLaunch('codex', cliArgs, yolo);
|
||||||
execRuntime(resolvedRuntime ?? 'codex', cliArgs, {
|
execRuntime('codex', cliArgs, { ...process.env, ...harnessEnv('codex') });
|
||||||
...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(
|
ensureRuntimeConfig('opencode', join(harnessHome('opencode'), 'opencode', 'AGENTS.md'));
|
||||||
'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, context.fleet, launchEnv);
|
recordLaunch('opencode', args, yolo);
|
||||||
execRuntime(resolvedRuntime ?? 'opencode', args, {
|
execRuntime('opencode', args, { ...process.env, ...harnessEnv('opencode') });
|
||||||
...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 = composeContract('pi', mosaicHome, contractEnv);
|
const prompt = buildRuntimePrompt('pi');
|
||||||
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());
|
||||||
@@ -1388,11 +964,8 @@ function launchRuntime(
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
recordLaunch('pi', cliArgs, yolo, context.fleet, launchEnv);
|
recordLaunch('pi', cliArgs, yolo);
|
||||||
if (process.env['MOSAIC_LAUNCH_ID']) {
|
execLeaseGatedRuntime('pi', cliArgs);
|
||||||
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
|
|
||||||
}
|
|
||||||
execLeaseGatedRuntime('pi', cliArgs, launchEnv, false, context.fleet);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1420,71 +993,32 @@ 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'] : [];
|
||||||
// The interpreter that starts the lease gate must not itself come off an ambient PATH:
|
|
||||||
// a shim here does not bypass one check, it replaces the process that enforces all of
|
|
||||||
// them. On the fleet path take the same root-owned capability the helper already
|
|
||||||
// requires. The operator path keeps name resolution, as it does everywhere else.
|
|
||||||
const interpreter = fleet ? trustedCapability('python3') : 'python3';
|
|
||||||
execRuntime(
|
execRuntime(
|
||||||
interpreter,
|
'python3',
|
||||||
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
[launcher, ...dangerousArgs, '--runtime', runtime, '--', runtime, ...args],
|
||||||
{
|
{
|
||||||
...baseEnv,
|
...baseEnv,
|
||||||
...harnessEnv(runtime, fleet),
|
...harnessEnv(runtime),
|
||||||
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. */
|
/** exec into the runtime, replacing the current process. */
|
||||||
export function launchFleetRuntime(
|
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
||||||
runtime: RuntimeName,
|
|
||||||
args: string[],
|
|
||||||
declaredEnv: Readonly<Record<string, string>>,
|
|
||||||
fleet: FleetHarnessContext,
|
|
||||||
): never {
|
|
||||||
return launchRuntime(runtime, args, false, {
|
|
||||||
mosaicHome: fleet.mosaicHome,
|
|
||||||
fleet,
|
|
||||||
declaredEnv,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* exec into the runtime, replacing the current process.
|
|
||||||
*
|
|
||||||
* `cmd` is either a bare name -- the operator path, where the OS resolves it against the
|
|
||||||
* caller's own PATH -- or an already-resolved executable, which is what every fleet
|
|
||||||
* launch passes. In the resolved case the exact validated path is spawned and its
|
|
||||||
* identity is re-confirmed first, so the thing that was checked is the thing that runs.
|
|
||||||
*/
|
|
||||||
function execRuntime(
|
|
||||||
cmd: string | ResolvedExecutable,
|
|
||||||
args: string[],
|
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
|
||||||
): void {
|
|
||||||
const label = typeof cmd === 'string' ? cmd : cmd.path;
|
|
||||||
try {
|
try {
|
||||||
let target: string;
|
|
||||||
if (typeof cmd === 'string') {
|
|
||||||
target = cmd;
|
|
||||||
} else {
|
|
||||||
assertUnchangedSinceValidation(cmd, cmd.path);
|
|
||||||
target = cmd.path;
|
|
||||||
}
|
|
||||||
// Use execFileSync with inherited stdio to replace the process
|
// Use execFileSync with inherited stdio to replace the process
|
||||||
const result = spawnSync(target, args, {
|
const result = spawnSync(cmd, args, {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env,
|
env,
|
||||||
});
|
});
|
||||||
process.exit(result.status ?? 0);
|
process.exit(result.status ?? 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[mosaic] Failed to launch ${label}:`, err instanceof Error ? err.message : err);
|
console.error(`[mosaic] Failed to launch ${cmd}:`, err instanceof Error ? err.message : err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,304 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
|
||||||
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;
|
|
||||||
/** Active installed Mosaic root; supplies the canonical authored runtime base. */
|
|
||||||
readonly mosaicHome?: 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 mosaicHome = resolve(options.mosaicHome ?? join(homedir(), '.config', 'mosaic'));
|
|
||||||
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 credentialLink = join(agentDir, homeName, credentialName);
|
|
||||||
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 }],
|
|
||||||
[
|
|
||||||
join(homeName, '.mosaic-managed-links.json'),
|
|
||||||
{ type: 'file', content: json({ links: { [credentialLink]: credentialTarget } }) },
|
|
||||||
],
|
|
||||||
];
|
|
||||||
if (harness === 'claude') {
|
|
||||||
entries.push([
|
|
||||||
join(homeName, '.claude.json'),
|
|
||||||
{ type: 'file', content: json(onboardingState(mosaicHome)) },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
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(mosaicHome: string): Record<string, unknown> {
|
|
||||||
const settingsPath = join(mosaicHome, 'runtime', 'claude', 'settings.json');
|
|
||||||
let authored: unknown;
|
|
||||||
try {
|
|
||||||
authored = JSON.parse(readFileSync(settingsPath, 'utf8')) as unknown;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new FleetAgentScaffoldError(
|
|
||||||
'invalid-request',
|
|
||||||
`canonical Claude settings are unavailable or invalid at ${settingsPath}: ${detail}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof authored !== 'object' ||
|
|
||||||
authored === null ||
|
|
||||||
Array.isArray(authored) ||
|
|
||||||
!('mcpServers' in authored) ||
|
|
||||||
typeof authored.mcpServers !== 'object' ||
|
|
||||||
authored.mcpServers === null ||
|
|
||||||
Array.isArray(authored.mcpServers)
|
|
||||||
) {
|
|
||||||
throw new FleetAgentScaffoldError(
|
|
||||||
'invalid-request',
|
|
||||||
`canonical Claude settings lack an mcpServers object: ${settingsPath}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: authored.mcpServers };
|
|
||||||
}
|
|
||||||
|
|
||||||
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,7 +23,6 @@ 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
|
||||||
@@ -570,15 +569,8 @@ 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:
|
||||||
base = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8"))
|
settings = json.loads(CLAUDE_SETTINGS.read_text(encoding="utf-8"))
|
||||||
overlay = json.loads(CLAUDE_LEASE_OVERLAY.read_text(encoding="utf-8"))
|
hooks = settings["hooks"]
|
||||||
# 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,7 +6,6 @@ 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,
|
||||||
@@ -43,19 +42,6 @@ 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');
|
||||||
@@ -378,7 +364,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 = (await readGatedClaudeSettings()) as unknown as {
|
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) 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 === '.*');
|
||||||
@@ -674,7 +660,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 = (await readGatedClaudeSettings()) as unknown as {
|
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
||||||
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
|
hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string }> }>>;
|
||||||
};
|
};
|
||||||
expect(
|
expect(
|
||||||
@@ -970,7 +956,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 = (await readGatedClaudeSettings()) as unknown as {
|
const settings = JSON.parse(await readFile(claudeSettingsPath, 'utf8')) as {
|
||||||
hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> };
|
hooks: { PreToolUse: Array<{ matcher?: string; hooks: Array<{ command: string }> }> };
|
||||||
};
|
};
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production composition uses universal last-layer-wins array replacement. The
|
|
||||||
// lease overlay therefore carries complete affected event arrays, including the
|
|
||||||
// two QA carry-forward entries needed to avoid dropping non-lease hooks.
|
|
||||||
function deepMerge(base: Json, overlay: Json): Json {
|
|
||||||
if (Array.isArray(base) && Array.isArray(overlay)) return overlay;
|
|
||||||
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,
|
|
||||||
hooks: {
|
|
||||||
...(preSplit['hooks'] as JsonObject),
|
|
||||||
Stop: [
|
|
||||||
{
|
|
||||||
hooks: [
|
|
||||||
{
|
|
||||||
type: 'command',
|
|
||||||
command: '~/.config/mosaic/tools/qa/reflect-stop-hook.sh',
|
|
||||||
timeout: 15,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
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('carries six lease commands plus exactly two deliberate QA carry-forward commands', () => {
|
|
||||||
const overlay = readJson(overlayPath);
|
|
||||||
expect(Object.keys(overlay)).toEqual(['hooks']);
|
|
||||||
|
|
||||||
const commands = hookCommands(overlay);
|
|
||||||
const lease = commands.filter((command) =>
|
|
||||||
/mutator-gate|receipt-observer|promote-|revoke-lease/.test(command),
|
|
||||||
);
|
|
||||||
const qa = commands.filter((command) => /prevent-memory-write|reflect-stop/.test(command));
|
|
||||||
expect(lease).toHaveLength(6);
|
|
||||||
expect(qa).toHaveLength(2);
|
|
||||||
expect(commands).toHaveLength(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['prevent-memory-write', 'reflect-stop'])(
|
|
||||||
'fails lossless reconstruction if QA carry-forward %s is removed',
|
|
||||||
(marker) => {
|
|
||||||
const base = readJson(basePath);
|
|
||||||
const overlay = readJson(overlayPath);
|
|
||||||
const expected = {
|
|
||||||
...readJson(gatedFixturePath),
|
|
||||||
mcpServers: { 'sequential-thinking': sequentialThinking },
|
|
||||||
};
|
|
||||||
const hooks = overlay['hooks'] as JsonObject;
|
|
||||||
const mutated: JsonObject = {
|
|
||||||
hooks: Object.fromEntries(
|
|
||||||
Object.entries(hooks).map(([event, entries]) => [
|
|
||||||
event,
|
|
||||||
Array.isArray(entries)
|
|
||||||
? entries.filter((entry) => !JSON.stringify(entry).includes(marker))
|
|
||||||
: entries,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
expect(normalize(deepMerge(base, mutated))).not.toEqual(normalize(expected));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
{
|
|
||||||
"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