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,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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
|||||||
MODE="apply"
|
MODE="apply"
|
||||||
RUNTIME="all"
|
RUNTIME="all"
|
||||||
STRICT_CHECK=0
|
STRICT_CHECK=0
|
||||||
CLAUDE_CONFIG_DIR=""
|
|
||||||
|
|
||||||
PKG="@modelcontextprotocol/server-sequential-thinking"
|
PKG="@modelcontextprotocol/server-sequential-thinking"
|
||||||
|
|
||||||
@@ -30,14 +29,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
STRICT_CHECK=1
|
STRICT_CHECK=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--claude-config-dir)
|
|
||||||
if [[ $# -lt 2 ]]; then
|
|
||||||
err "--claude-config-dir requires an absolute seat config directory"
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
CLAUDE_CONFIG_DIR="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
*)
|
||||||
err "Unknown argument: $1"
|
err "Unknown argument: $1"
|
||||||
exit 2
|
exit 2
|
||||||
@@ -76,19 +67,11 @@ warm_package() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
check_claude_config() {
|
check_claude_config() {
|
||||||
CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
# Claude reads MCP definitions from .claude.json, not settings.json. The
|
p = Path.home() / ".claude" / "settings.json"
|
||||||
# settings.json fallback preserves legacy operator flows until their config is migrated.
|
if not p.exists():
|
||||||
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
||||||
p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json"
|
|
||||||
if not p.exists() and not config_dir:
|
|
||||||
p = Path.home() / ".claude" / "settings.json"
|
|
||||||
# Only explicit fleet seats require a private, non-symlink config. Operator
|
|
||||||
# config remains compatible with pre-existing permission conventions.
|
|
||||||
if not p.exists() or p.is_symlink() or (config_dir and (p.stat().st_mode & 0o077) != 0):
|
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
try:
|
try:
|
||||||
data = json.loads(p.read_text(encoding="utf-8"))
|
data = json.loads(p.read_text(encoding="utf-8"))
|
||||||
@@ -109,15 +92,10 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply_claude_config() {
|
apply_claude_config() {
|
||||||
CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
# Claude reads MCP definitions from .claude.json for both operator and
|
p = Path.home() / ".claude" / "settings.json"
|
||||||
# explicitly isolated fleet config dirs. The checker retains a settings.json
|
|
||||||
# fallback only to avoid breaking legacy operator configurations.
|
|
||||||
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
||||||
p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json"
|
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if p.exists():
|
if p.exists():
|
||||||
try:
|
try:
|
||||||
@@ -139,7 +117,7 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
check_codex_config() {
|
check_codex_config() {
|
||||||
local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml"
|
local cfg="$HOME/.codex/config.toml"
|
||||||
[[ -f "$cfg" ]] || return 1
|
[[ -f "$cfg" ]] || return 1
|
||||||
grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \
|
grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \
|
||||||
grep -q '^command = "npx"' "$cfg" && \
|
grep -q '^command = "npx"' "$cfg" && \
|
||||||
@@ -147,7 +125,7 @@ check_codex_config() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply_codex_config() {
|
apply_codex_config() {
|
||||||
local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml"
|
local cfg="$HOME/.codex/config.toml"
|
||||||
mkdir -p "$(dirname "$cfg")"
|
mkdir -p "$(dirname "$cfg")"
|
||||||
[[ -f "$cfg" ]] || touch "$cfg"
|
[[ -f "$cfg" ]] || touch "$cfg"
|
||||||
|
|
||||||
@@ -170,11 +148,10 @@ apply_codex_config() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
check_opencode_config() {
|
check_opencode_config() {
|
||||||
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
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 = Path.home() / ".config" / "opencode" / "config.json"
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
try:
|
try:
|
||||||
@@ -197,11 +174,10 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply_opencode_config() {
|
apply_opencode_config() {
|
||||||
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
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 = Path.home() / ".config" / "opencode" / "config.json"
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if p.exists():
|
if p.exists():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -286,24 +286,12 @@ _build_runtime_bin_prefix() {
|
|||||||
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
||||||
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
||||||
|
|
||||||
# A seat scaffolded by `mosaic fleet agent new` owns its harness home, settings
|
|
||||||
# overlay and auth bundle; launching it through `mosaic fleet launch` is what makes
|
|
||||||
# ~/.mosaic real for a roster-started pane instead of a directory nothing reads.
|
|
||||||
# Detection uses $PANE_HOME/.mosaic because the pane environment is cleared below,
|
|
||||||
# so `mosaic fleet launch` resolves the same root from HOME and the two agree.
|
|
||||||
FLEET_SEAT_DIR="$PANE_HOME/.mosaic/fleet/agents/$AGENT_NAME"
|
|
||||||
FLEET_SEAT=0
|
|
||||||
[ -f "$FLEET_SEAT_DIR/profile.json" ] && FLEET_SEAT=1
|
|
||||||
|
|
||||||
_ensure_claude_workdir_trusted() {
|
_ensure_claude_workdir_trusted() {
|
||||||
local workdir="$1"
|
local workdir="$1"
|
||||||
local claude_json="$2"
|
|
||||||
local resolved
|
local resolved
|
||||||
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
||||||
if [ -z "$claude_json" ]; then
|
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
||||||
claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
|
||||||
claude_json="${claude_json:-$HOME/.claude.json}"
|
claude_json="${claude_json:-$HOME/.claude.json}"
|
||||||
fi
|
|
||||||
command -v python3 >/dev/null 2>&1 || return 1
|
command -v python3 >/dev/null 2>&1 || return 1
|
||||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
||||||
import json, os, sys, tempfile
|
import json, os, sys, tempfile
|
||||||
@@ -337,23 +325,11 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
||||||
# Trust belongs to the home the seat will actually run in. Writing it to the
|
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
|
||||||
# operator's ~/.claude.json would leave the seat prompting on its first turn.
|
|
||||||
SEAT_CLAUDE_JSON=""
|
|
||||||
if [ "$FLEET_SEAT" = 1 ] && [ -d "$FLEET_SEAT_DIR/.claude" ]; then
|
|
||||||
SEAT_CLAUDE_JSON="$FLEET_SEAT_DIR/.claude/.claude.json"
|
|
||||||
fi
|
|
||||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" "$SEAT_CLAUDE_JSON" || \
|
|
||||||
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$FLEET_SEAT" = 1 ]; then
|
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||||
# --dangerous keeps the seat on the same permissions footing `mosaic yolo` gave it;
|
|
||||||
# the composition, not the roster, decides harness home, bundle and settings.
|
|
||||||
LAUNCH_COMMAND=(mosaic fleet launch "$AGENT_NAME" --dangerous)
|
|
||||||
else
|
|
||||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
|
||||||
fi
|
|
||||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||||
|
|
||||||
|
|||||||
@@ -409,23 +409,4 @@ if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
|||||||
fail "exact stop trusted an ambient socket"
|
fail "exact stop trusted an ambient socket"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# A seat scaffolded under ~/.mosaic owns its harness home, so the pane launches
|
|
||||||
# through the composition instead of the operator's own home. --dangerous keeps the
|
|
||||||
# seat on the permissions footing `mosaic yolo` gave it.
|
|
||||||
: > "$TMUX_CALLS"
|
|
||||||
HOME_SEAT="$ROOT/seat"
|
|
||||||
write_generated "$HOME_SEAT" "coder-seat"
|
|
||||||
mkdir -p "$HOME_SEAT/.mosaic/fleet/agents/coder-seat"
|
|
||||||
printf '{"schema":1,"harness":"pi","bundle":"primary"}\n' \
|
|
||||||
> "$HOME_SEAT/.mosaic/fleet/agents/coder-seat/profile.json"
|
|
||||||
run_start "$HOME_SEAT" "coder-seat"
|
|
||||||
seat_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
|
||||||
echo "$seat_args" | grep -qxF 'fleet' || fail "scaffolded seat did not launch through fleet launch"
|
|
||||||
echo "$seat_args" | grep -qxF 'launch' || fail "scaffolded seat did not launch through fleet launch"
|
|
||||||
echo "$seat_args" | grep -qxF 'coder-seat' || fail "fleet launch did not name the seat"
|
|
||||||
echo "$seat_args" | grep -qxF -- '--dangerous' || fail "scaffolded seat lost dangerous permissions"
|
|
||||||
if echo "$seat_args" | grep -qxF 'yolo'; then
|
|
||||||
fail "scaffolded seat still launched through mosaic yolo"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo 'ok - start-agent-session generated environment boundary'
|
echo 'ok - start-agent-session generated environment boundary'
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { registerLaunchCommands } from './commands/launch.js';
|
|||||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
||||||
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
||||||
import { registerAuthCommand } from './commands/auth.js';
|
import { registerAuthCommand } from './commands/auth.js';
|
||||||
import { registerFleetAuthCommands } from './commands/fleet-auth-command.js';
|
|
||||||
import { registerFederationCommand } from './commands/federation.js';
|
import { registerFederationCommand } from './commands/federation.js';
|
||||||
import { registerGatewayCommand } from './commands/gateway.js';
|
import { registerGatewayCommand } from './commands/gateway.js';
|
||||||
import {
|
import {
|
||||||
@@ -351,7 +350,7 @@ sessionsCmd
|
|||||||
|
|
||||||
// ─── auth ────────────────────────────────────────────────────────────────
|
// ─── auth ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerFleetAuthCommands(registerAuthCommand(program));
|
registerAuthCommand(program);
|
||||||
|
|
||||||
// ─── gateway ──────────────────────────────────────────────────────────
|
// ─── gateway ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -139,11 +139,10 @@ function printUser(u: UserDto): void {
|
|||||||
* Keeping packages/auth as a pure server-side library avoids adding commander
|
* Keeping packages/auth as a pure server-side library avoids adding commander
|
||||||
* and CLI tooling as dependencies there.
|
* and CLI tooling as dependencies there.
|
||||||
*/
|
*/
|
||||||
/** Returns the `auth` command so local (non-gateway) verbs can be attached to it. */
|
export function registerAuthCommand(parent: Command): void {
|
||||||
export function registerAuthCommand(parent: Command): Command {
|
|
||||||
const auth = parent
|
const auth = parent
|
||||||
.command('auth')
|
.command('auth')
|
||||||
.description('Manage authentication: local credential bundles, and gateway users and sessions')
|
.description('Manage gateway authentication, users, SSO providers, and sessions')
|
||||||
.configureHelp({ sortSubcommands: true })
|
.configureHelp({ sortSubcommands: true })
|
||||||
.action(() => {
|
.action(() => {
|
||||||
auth.outputHelp();
|
auth.outputHelp();
|
||||||
@@ -329,6 +328,4 @@ export function registerAuthCommand(parent: Command): Command {
|
|||||||
);
|
);
|
||||||
void opts;
|
void opts;
|
||||||
});
|
});
|
||||||
|
|
||||||
return auth;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { registerFleetAdoptCommand } from './fleet-adopt-command.js';
|
|
||||||
|
|
||||||
let root: string | undefined;
|
|
||||||
|
|
||||||
interface Harness {
|
|
||||||
readonly home: string;
|
|
||||||
readonly out: string[];
|
|
||||||
readonly err: string[];
|
|
||||||
readonly run: (argv: string[]) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach((): void => {
|
|
||||||
process.exitCode = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.exitCode = undefined;
|
|
||||||
if (root) await rm(root, { recursive: true, force: true });
|
|
||||||
root = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function harness(): Promise<Harness> {
|
|
||||||
root = await mkdtemp(join(tmpdir(), 'mosaic-adopt-cmd-'));
|
|
||||||
const home = join(root, '.mosaic');
|
|
||||||
const out: string[] = [];
|
|
||||||
const err: string[] = [];
|
|
||||||
vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]): void => {
|
|
||||||
out.push(parts.map(String).join(' '));
|
|
||||||
});
|
|
||||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown): boolean => {
|
|
||||||
err.push(String(chunk));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
const fleet = program.command('fleet');
|
|
||||||
registerFleetAdoptCommand(fleet, { fleetDataHome: home });
|
|
||||||
return {
|
|
||||||
home,
|
|
||||||
out,
|
|
||||||
err,
|
|
||||||
run: async (argv: string[]): Promise<void> => {
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'adopt', ...argv]);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function realAliasDirectory(home: string, harnessName: string): string {
|
|
||||||
const path = join(home, 'auth', harnessName, 'primary');
|
|
||||||
mkdirSync(path, { recursive: true });
|
|
||||||
writeFileSync(join(path, '.credentials.json'), '{"token":"kept"}');
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function seat(home: string, name: string, profile: Record<string, unknown>): void {
|
|
||||||
const dir = join(home, 'fleet', 'agents', name);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
writeFileSync(join(dir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function seatDirectory(home: string, agent: string, plural: string, name: string): string {
|
|
||||||
const path = join(home, 'fleet', 'agents', agent, '.claude', plural, name);
|
|
||||||
mkdirSync(path, { recursive: true });
|
|
||||||
writeFileSync(join(path, 'marker.txt'), 'kept');
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('mosaic fleet adopt', () => {
|
|
||||||
it('says there is nothing to adopt on a clean host', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
|
|
||||||
await h.run([]);
|
|
||||||
|
|
||||||
expect(h.out.join('\n')).toContain('Nothing to adopt');
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
// A read-only listing that exits non-zero is one people stop running, so the scan reports
|
|
||||||
// and stays out of the way.
|
|
||||||
it('lists each finding with the command that resolves it, and exits zero', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
const path = realAliasDirectory(h.home, 'claude');
|
|
||||||
|
|
||||||
await h.run([]);
|
|
||||||
|
|
||||||
const printed = h.out.join('\n');
|
|
||||||
expect(printed).toContain(path);
|
|
||||||
expect(printed).toContain('resolve: mosaic fleet adopt bundle --harness claude --as <account>');
|
|
||||||
expect(printed).toContain('1 found, 0 needing a decision before adoption. Nothing was moved.');
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('separates findings it can resolve from findings that need a decision first', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' });
|
|
||||||
seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer');
|
|
||||||
mkdirSync(join(h.home, 'plugins', 'reviewer'), { recursive: true });
|
|
||||||
|
|
||||||
await h.run([]);
|
|
||||||
|
|
||||||
const printed = h.out.join('\n');
|
|
||||||
expect(printed).toContain('blocked (destination occupied)');
|
|
||||||
expect(printed).toContain('1 found, 1 needing a decision before adoption.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adopts a bundle and reports where the credentials went and what the alias points at', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
realAliasDirectory(h.home, 'claude');
|
|
||||||
|
|
||||||
await h.run(['bundle', '--harness', 'claude', '--as', 'jason_woltje.com']);
|
|
||||||
|
|
||||||
const target = join(h.home, 'auth', 'claude', 'jason_woltje.com');
|
|
||||||
expect(readFileSync(join(target, '.credentials.json'), 'utf8')).toBe('{"token":"kept"}');
|
|
||||||
const printed = h.out.join('\n');
|
|
||||||
expect(printed).toContain(`bundle: ${target}`);
|
|
||||||
expect(printed).toContain('-> jason_woltje.com');
|
|
||||||
// The name is the operator's claim about the account; only a listing shows what is in it.
|
|
||||||
expect(printed).toContain('mosaic auth list --harness claude');
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown harness instead of building a path out of it', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
|
|
||||||
await h.run(['bundle', '--harness', 'nonsense', '--as', 'x']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('--harness must be one of: claude, codex, opencode, pi');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exits non-zero and names the failure when there is nothing to adopt', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
|
|
||||||
await h.run(['bundle', '--harness', 'pi', '--as', 'jason_woltje.com']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('mosaic fleet adopt bundle failed (nothing-to-adopt)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adopts a plugin into the store and says the next launch links it back', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
seat(h.home, 'uc-e6-coder', {
|
|
||||||
schema: 1,
|
|
||||||
harness: 'claude',
|
|
||||||
bundle: 'primary',
|
|
||||||
plugins: ['reviewer'],
|
|
||||||
});
|
|
||||||
seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer');
|
|
||||||
|
|
||||||
await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']);
|
|
||||||
|
|
||||||
expect(readFileSync(join(h.home, 'plugins', 'reviewer', 'marker.txt'), 'utf8')).toBe('kept');
|
|
||||||
expect(h.out.join('\n')).toContain('next launch links it back from the store');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('says plainly when no seat uses the adopted entry yet', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' });
|
|
||||||
seatDirectory(h.home, 'uc-e6-coder', 'plugins', 'reviewer');
|
|
||||||
|
|
||||||
await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']);
|
|
||||||
|
|
||||||
expect(h.out.join('\n')).toContain("is not listed in uc-e6-coder's profile");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adopts a skill into the skill store, not the plugin store', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
seat(h.home, 'uc-e6-rev', { schema: 1, harness: 'claude', bundle: 'primary' });
|
|
||||||
seatDirectory(h.home, 'uc-e6-rev', 'skills', 'spec-audit');
|
|
||||||
|
|
||||||
await h.run(['skill', 'spec-audit', '--seat', 'uc-e6-rev']);
|
|
||||||
|
|
||||||
expect(readFileSync(join(h.home, 'skills', 'spec-audit', 'marker.txt'), 'utf8')).toBe('kept');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves an already-linked entry alone and exits non-zero', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
seat(h.home, 'uc-e6-coder', { schema: 1, harness: 'claude', bundle: 'primary' });
|
|
||||||
mkdirSync(join(h.home, 'plugins', 'reviewer'), { recursive: true });
|
|
||||||
const installRoot = join(h.home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins');
|
|
||||||
mkdirSync(installRoot, { recursive: true });
|
|
||||||
symlinkSync(join(h.home, 'plugins', 'reviewer'), join(installRoot, 'reviewer'));
|
|
||||||
|
|
||||||
await h.run(['plugin', 'reviewer', '--seat', 'uc-e6-coder']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('already a link into the store');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
/**
|
|
||||||
* `mosaic fleet adopt` -- resolve the real directories that sit where a managed link belongs.
|
|
||||||
*
|
|
||||||
* Launch refuses to delete anything an operator put on a managed path, which is right, but on
|
|
||||||
* its own it leaves the operator holding a composition error and no way forward. This command
|
|
||||||
* is the way forward: bare, it lists every such directory and the command that resolves it;
|
|
||||||
* with a verb, it moves one of them where it belongs.
|
|
||||||
*
|
|
||||||
* The bare scan reads only, and exits zero whatever it finds. It is meant to be safe to run
|
|
||||||
* out of curiosity, and a non-zero exit from a read-only listing would make it something
|
|
||||||
* people avoid running.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Command } from 'commander';
|
|
||||||
import {
|
|
||||||
AdoptionError,
|
|
||||||
type StoreKind,
|
|
||||||
promoteBundleAlias,
|
|
||||||
promoteStoreEntry,
|
|
||||||
scanAdoptions,
|
|
||||||
} from '../fleet/adoption.js';
|
|
||||||
import type { CredentialHarness } from '../fleet/credential-sharing.js';
|
|
||||||
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
|
|
||||||
|
|
||||||
const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi'];
|
|
||||||
|
|
||||||
export interface FleetAdoptCommandDeps {
|
|
||||||
/** Test seam for the user-owned ~/.mosaic root. */
|
|
||||||
readonly fleetDataHome?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireHarness(value: string | undefined): CredentialHarness {
|
|
||||||
if (value === undefined || !HARNESSES.includes(value as CredentialHarness)) {
|
|
||||||
throw new AdoptionError('invalid-request', `--harness must be one of: ${HARNESSES.join(', ')}`);
|
|
||||||
}
|
|
||||||
return value as CredentialHarness;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireSeat(value: string | undefined): string {
|
|
||||||
if (value === undefined || value.trim() === '') {
|
|
||||||
throw new AdoptionError(
|
|
||||||
'invalid-request',
|
|
||||||
'give the seat this directory belongs to: --seat <agent>',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fail(error: unknown, verb: string): void {
|
|
||||||
process.exitCode = 1;
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
const code = error instanceof AdoptionError ? error.code : 'failed';
|
|
||||||
process.stderr.write(
|
|
||||||
`mosaic fleet adopt${verb === '' ? '' : ` ${verb}`} failed (${code}): ${message}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Registers the adoption scan and its three promotion verbs. */
|
|
||||||
export function registerFleetAdoptCommand(
|
|
||||||
fleetCommand: Command,
|
|
||||||
deps: FleetAdoptCommandDeps = {},
|
|
||||||
): void {
|
|
||||||
const dataHome = (): string => deps.fleetDataHome ?? defaultFleetDataHome();
|
|
||||||
|
|
||||||
const adopt = fleetCommand
|
|
||||||
.command('adopt')
|
|
||||||
.description('Find and resolve real directories occupying paths the fleet manages with links')
|
|
||||||
.action((): void => {
|
|
||||||
try {
|
|
||||||
const findings = scanAdoptions(dataHome());
|
|
||||||
if (findings.length === 0) {
|
|
||||||
console.log('Nothing to adopt: no real directory occupies a managed path.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const finding of findings) {
|
|
||||||
console.log(finding.path);
|
|
||||||
console.log(` ${finding.reason}`);
|
|
||||||
console.log(
|
|
||||||
finding.blocked === undefined
|
|
||||||
? ` resolve: ${finding.remedy}`
|
|
||||||
: ` blocked (${finding.blocked}): ${finding.remedy}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const blocked = findings.filter((finding) => finding.blocked !== undefined).length;
|
|
||||||
console.log(
|
|
||||||
`\n${String(findings.length)} found, ${String(blocked)} needing a decision before adoption. Nothing was moved.`,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
fail(error, '');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
adopt
|
|
||||||
.command('bundle')
|
|
||||||
.description(`Adopt a real directory on the "primary" alias path as a named bundle`)
|
|
||||||
.requiredOption('--harness <harness>', `Harness: ${HARNESSES.join(', ')}`)
|
|
||||||
.requiredOption('--as <bundle>', 'Account this directory holds, e.g. jason_woltje.com')
|
|
||||||
.action((options: { harness?: string; as: string }): void => {
|
|
||||||
try {
|
|
||||||
const result = promoteBundleAlias(dataHome(), requireHarness(options.harness), options.as);
|
|
||||||
console.log(`Adopted ${result.from}`);
|
|
||||||
console.log(` bundle: ${result.to}`);
|
|
||||||
console.log(` alias: ${result.alias} -> ${result.bundle}`);
|
|
||||||
console.log(
|
|
||||||
`\nCheck the account it actually holds before trusting the name:\n mosaic auth list --harness ${result.harness}`,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
fail(error, 'bundle');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const store of ['plugin', 'skill'] as const) {
|
|
||||||
adopt
|
|
||||||
.command(`${store} <name>`)
|
|
||||||
.description(`Move a real ${store} directory out of a seat and into the central store`)
|
|
||||||
.requiredOption('--seat <agent>', 'Seat the directory currently sits in')
|
|
||||||
.action((name: string, options: { seat?: string }): void => {
|
|
||||||
try {
|
|
||||||
const result = promoteStoreEntry(
|
|
||||||
dataHome(),
|
|
||||||
requireSeat(options.seat),
|
|
||||||
store as StoreKind,
|
|
||||||
name,
|
|
||||||
);
|
|
||||||
console.log(`Adopted ${result.from}`);
|
|
||||||
console.log(` store: ${result.to}`);
|
|
||||||
console.log(
|
|
||||||
result.listedInProfile
|
|
||||||
? `\n"${result.name}" is listed in ${result.agent}'s profile, so its next launch links it back from the store.`
|
|
||||||
: `\n"${result.name}" is not listed in ${result.agent}'s profile, so no seat uses it yet. It is now vetted store content any seat can be given.`,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
fail(error, store);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
||||||
import {
|
|
||||||
lstat,
|
|
||||||
mkdtemp,
|
|
||||||
readFile,
|
|
||||||
readdir,
|
|
||||||
readlink,
|
|
||||||
rm,
|
|
||||||
symlink,
|
|
||||||
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');
|
|
||||||
|
|
||||||
// Claude reaches its bundle through CLAUDE_SECURESTORAGE_CONFIG_DIR at launch,
|
|
||||||
// so no credential link is planted in the seat home.
|
|
||||||
expect(await files(agent)).toEqual([
|
|
||||||
'.claude/.claude.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'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
JSON.parse(await readFile(join(agent, '.claude', '.mosaic-managed-links.json'), 'utf8')),
|
|
||||||
).toEqual({ links: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('plants a managed credential link for a harness that is not shared by environment', async (): Promise<void> => {
|
|
||||||
const dataHome = await fleetDataHome();
|
|
||||||
await program(dataHome).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'agent',
|
|
||||||
'new',
|
|
||||||
'pi-seat',
|
|
||||||
'--harness',
|
|
||||||
'pi',
|
|
||||||
]);
|
|
||||||
const agent = join(dataHome, 'fleet', 'agents', 'pi-seat');
|
|
||||||
const credentialTarget = join(dataHome, 'auth', 'pi', 'primary', 'auth.json');
|
|
||||||
|
|
||||||
expect(await readlink(join(agent, '.pi', 'auth.json'))).toBe(credentialTarget);
|
|
||||||
expect(
|
|
||||||
JSON.parse(await readFile(join(agent, '.pi', '.mosaic-managed-links.json'), 'utf8')),
|
|
||||||
).toEqual({ links: { [join(agent, '.pi', 'auth.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();
|
|
||||||
const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'pi-seat', '--harness', 'pi'];
|
|
||||||
await program(dataHome).parseAsync(command);
|
|
||||||
const credential = join(dataHome, 'fleet', 'agents', 'pi-seat', '.pi', 'auth.json');
|
|
||||||
expect((await lstat(credential)).isSymbolicLink()).toBe(true);
|
|
||||||
await program(dataHome).parseAsync(command);
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tolerates a credential link left by a scaffold that predates environment sharing', async (): Promise<void> => {
|
|
||||||
const dataHome = await fleetDataHome();
|
|
||||||
const command = ['node', 'mosaic', 'fleet', 'agent', 'new', 'mira'];
|
|
||||||
await program(dataHome).parseAsync(command);
|
|
||||||
const seatHome = join(dataHome, 'fleet', 'agents', 'mira', '.claude');
|
|
||||||
const credential = join(seatHome, '.credentials.json');
|
|
||||||
const target = join(dataHome, 'auth', 'claude', 'primary', '.credentials.json');
|
|
||||||
await symlink(target, credential);
|
|
||||||
await writeFile(
|
|
||||||
join(seatHome, '.mosaic-managed-links.json'),
|
|
||||||
`${JSON.stringify({ links: { [credential]: target } }, null, 2)}\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await program(dataHome).parseAsync(command);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
expect((await lstat(credential)).isSymbolicLink()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scaffolds against canonical settings that declare no mcpServers', async (): Promise<void> => {
|
|
||||||
// The framework's shipped runtime/claude/settings.json has no mcpServers key, so
|
|
||||||
// requiring one refused to scaffold any Claude seat on a clean install. Measured on a
|
|
||||||
// greenfield Debian 13 VM against framework main.
|
|
||||||
const dataHome = await fleetDataHome();
|
|
||||||
const command = program(dataHome);
|
|
||||||
const settings = join(root!, 'installed-mosaic', 'runtime', 'claude', 'settings.json');
|
|
||||||
await writeFile(settings, JSON.stringify({ model: 'opus', hooks: {} }));
|
|
||||||
|
|
||||||
await command.parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
const claudeJson = join(dataHome, 'fleet', 'agents', 'mira', '.claude', '.claude.json');
|
|
||||||
expect(JSON.parse(await readFile(claudeJson, 'utf8'))).toEqual({
|
|
||||||
hasCompletedOnboarding: true,
|
|
||||||
theme: 'dark',
|
|
||||||
mcpServers: {},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still refuses canonical settings whose mcpServers is the wrong shape', async (): Promise<void> => {
|
|
||||||
const dataHome = await fleetDataHome();
|
|
||||||
const command = program(dataHome);
|
|
||||||
const settings = join(root!, 'installed-mosaic', 'runtime', 'claude', 'settings.json');
|
|
||||||
await writeFile(settings, JSON.stringify({ mcpServers: ['sequential-thinking'] }));
|
|
||||||
|
|
||||||
await command.parseAsync(['node', 'mosaic', 'fleet', 'agent', 'new', 'mira']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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: auth bundle "${result.profile['bundle']}" is not enrolled yet, so no credential exists at ${result.credentialTarget}. The seat will refuse to launch until it does.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} 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`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,365 +0,0 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
||||||
import { mkdtemp, readFile, rm } 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 { registerFleetAuthCommands, type FleetAuthCommandDeps } from './fleet-auth-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;
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Harness {
|
|
||||||
readonly home: string;
|
|
||||||
readonly out: string[];
|
|
||||||
readonly err: string[];
|
|
||||||
readonly logins: Array<{ command: string; args: readonly string[]; env: Record<string, string> }>;
|
|
||||||
run: (argv: string[]) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function harness(
|
|
||||||
overrides: Omit<FleetAuthCommandDeps, 'fleetDataHome'> = {},
|
|
||||||
): Promise<Harness> {
|
|
||||||
root = await mkdtemp(join(tmpdir(), 'mosaic-auth-cmd-'));
|
|
||||||
const home = join(root, '.mosaic');
|
|
||||||
const out: string[] = [];
|
|
||||||
const err: string[] = [];
|
|
||||||
const logins: Harness['logins'] = [];
|
|
||||||
|
|
||||||
vi.spyOn(console, 'log').mockImplementation((...parts: unknown[]): void => {
|
|
||||||
out.push(parts.map(String).join(' '));
|
|
||||||
});
|
|
||||||
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown): boolean => {
|
|
||||||
err.push(String(chunk));
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Every login is recorded regardless of which behaviour the test supplied, so a test can
|
|
||||||
// assert on what the harness was actually handed as well as on what it wrote.
|
|
||||||
const inner = overrides.runLogin ?? ((): number => 0);
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
const auth = program.command('auth');
|
|
||||||
registerFleetAuthCommands(auth, {
|
|
||||||
...overrides,
|
|
||||||
fleetDataHome: home,
|
|
||||||
runLogin: (command, args, env): number | null => {
|
|
||||||
logins.push({ command, args, env: { ...env } });
|
|
||||||
return inner(command, args, env);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
home,
|
|
||||||
out,
|
|
||||||
err,
|
|
||||||
logins,
|
|
||||||
run: async (argv: string[]): Promise<void> => {
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'auth', ...argv]);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A login that behaves: writes the credential where the harness would write it, using only the
|
|
||||||
* environment it was handed — the same way a real harness finds its home.
|
|
||||||
*/
|
|
||||||
function goodLogin(email?: string, status = 0): NonNullable<FleetAuthCommandDeps['runLogin']> {
|
|
||||||
return (command, _args, env): number => {
|
|
||||||
const dir =
|
|
||||||
command === 'claude'
|
|
||||||
? (env['CLAUDE_SECURESTORAGE_CONFIG_DIR'] ?? '')
|
|
||||||
: (env['PI_CODING_AGENT_DIR'] ?? env['CODEX_HOME'] ?? env['XDG_CONFIG_HOME'] ?? '');
|
|
||||||
writeFileSync(join(dir, command === 'claude' ? '.credentials.json' : 'auth.json'), '{}', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
if (email !== undefined) {
|
|
||||||
writeFileSync(
|
|
||||||
join(dir, command === 'claude' ? '.claude.json' : 'auth.json'),
|
|
||||||
JSON.stringify(
|
|
||||||
command === 'claude' ? { oauthAccount: { emailAddress: email } } : { account: { email } },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return status;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scaffoldSeat(
|
|
||||||
home: string,
|
|
||||||
name: string,
|
|
||||||
profile: Record<string, unknown> = { schema: 1, harness: 'claude', bundle: 'primary' },
|
|
||||||
): string {
|
|
||||||
const dir = join(home, 'fleet', 'agents', name);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
const path = join(dir, 'profile.json');
|
|
||||||
writeFileSync(path, `${JSON.stringify(profile, null, 2)}\n`);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('mosaic auth enroll', () => {
|
|
||||||
it('runs the harness login against the bundle directory and reports what landed', async () => {
|
|
||||||
const h = await harness({ runLogin: goodLogin('[email protected]') });
|
|
||||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
const bundleDir = join(h.home, 'auth', 'claude', 'jason_woltje.com');
|
|
||||||
expect(h.out.join('\n')).toContain(bundleDir);
|
|
||||||
expect(h.out.join('\n')).toContain('account: [email protected]');
|
|
||||||
const recorded = JSON.parse(await readFile(join(bundleDir, 'account.json'), 'utf8')) as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(recorded['emailAddress']).toBe('[email protected]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hands the harness its own home and credential directory, never an empty value', async () => {
|
|
||||||
const h = await harness({ runLogin: goodLogin() });
|
|
||||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
|
||||||
|
|
||||||
const bundleDir = join(h.home, 'auth', 'claude', 'jason_woltje.com');
|
|
||||||
expect(h.logins).toEqual([
|
|
||||||
{
|
|
||||||
command: 'claude',
|
|
||||||
args: [],
|
|
||||||
// An empty CLAUDE_SECURESTORAGE_CONFIG_DIR is not "unset" -- Claude resolves it to
|
|
||||||
// ~/.claude, the operator's own account -- so exporting one would quietly log the
|
|
||||||
// operator in over their own credentials instead of enrolling the seat's.
|
|
||||||
env: { CLAUDE_CONFIG_DIR: bundleDir, CLAUDE_SECURESTORAGE_CONFIG_DIR: bundleDir },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forwards login arguments to the harness', async () => {
|
|
||||||
const h = await harness({ runLogin: goodLogin() });
|
|
||||||
await h.run([
|
|
||||||
'enroll',
|
|
||||||
'--harness',
|
|
||||||
'pi',
|
|
||||||
'--bundle',
|
|
||||||
'jason_woltje.com',
|
|
||||||
'--login-arg',
|
|
||||||
'/login',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(h.logins[0]?.args).toEqual(['/login']);
|
|
||||||
expect(h.logins[0]?.env).toEqual({
|
|
||||||
PI_CODING_AGENT_DIR: join(h.home, 'auth', 'pi', 'jason_woltje.com'),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exits non-zero when the account that logged in is not the account the bundle claims', async () => {
|
|
||||||
const h = await harness({ runLogin: goodLogin('[email protected]') });
|
|
||||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'reviewer_example.com']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('[email protected]');
|
|
||||||
expect(h.err.join('')).toContain('one principal wearing two names');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails clearly when the harness is not installed', async () => {
|
|
||||||
const h = await harness({ runLogin: (): null => null });
|
|
||||||
await h.run(['enroll', '--harness', 'pi', '--bundle', 'someone_example.com']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('could not start "pi"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports a login that wrote nothing rather than calling the bundle enrolled', async () => {
|
|
||||||
const h = await harness({ runLogin: (): number => 0 });
|
|
||||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('login left no credential');
|
|
||||||
expect(h.err.join('')).toContain('nothing was assigned');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still checks the bundle when the harness exits non-zero on quit', async () => {
|
|
||||||
// Several harnesses exit non-zero on a normal quit after a successful login. The
|
|
||||||
// credential on disk is the fact that matters, not the exit status.
|
|
||||||
const h = await harness({ runLogin: goodLogin('[email protected]', 130) });
|
|
||||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
expect(h.out.join('\n')).toContain('account: [email protected]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates the directory and stops when the operator will run the login themselves', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com', '--no-login']);
|
|
||||||
|
|
||||||
expect(h.logins).toHaveLength(0);
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
expect(h.out.join('\n')).toContain('CLAUDE_SECURESTORAGE_CONFIG_DIR=');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a harness it does not know', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
await h.run(['enroll', '--harness', 'emacs', '--bundle', 'x']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('--harness must be one of');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mosaic auth assign', () => {
|
|
||||||
it('pegs a seat to a bundle and leaves every other profile field alone', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
const path = scaffoldSeat(h.home, 'uc-e6-rev', {
|
|
||||||
schema: 1,
|
|
||||||
harness: 'claude',
|
|
||||||
bundle: 'primary',
|
|
||||||
model: 'opus',
|
|
||||||
overlay: 'overlay.json',
|
|
||||||
env: { MOSAIC_AGENT_NAME: 'uc-e6-rev' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']);
|
|
||||||
|
|
||||||
const written = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>;
|
|
||||||
expect(written).toEqual({
|
|
||||||
schema: 1,
|
|
||||||
harness: 'claude',
|
|
||||||
bundle: 'reviewer_example.com',
|
|
||||||
model: 'opus',
|
|
||||||
overlay: 'overlay.json',
|
|
||||||
env: { MOSAIC_AGENT_NAME: 'uc-e6-rev' },
|
|
||||||
});
|
|
||||||
expect(h.out.join('\n')).toContain('uc-e6-rev: primary -> reviewer_example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('says the bundle is not enrolled, because the seat will refuse to launch until it is', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
scaffoldSeat(h.home, 'uc-e6-rev');
|
|
||||||
|
|
||||||
await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']);
|
|
||||||
|
|
||||||
expect(h.out.join('\n')).toContain('is not enrolled for claude');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is quiet about enrolment when the bundle really is enrolled', async () => {
|
|
||||||
const h = await harness({ runLogin: goodLogin('[email protected]') });
|
|
||||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'reviewer_example.com']);
|
|
||||||
scaffoldSeat(h.home, 'uc-e6-rev');
|
|
||||||
h.out.length = 0;
|
|
||||||
|
|
||||||
await h.run(['assign', 'uc-e6-rev', '--bundle', 'reviewer_example.com']);
|
|
||||||
|
|
||||||
expect(h.out.join('\n')).not.toContain('is not enrolled');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports an unchanged seat instead of rewriting it', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
scaffoldSeat(h.home, 'seat', { schema: 1, harness: 'pi', bundle: 'held_example.com' });
|
|
||||||
|
|
||||||
await h.run(['assign', 'seat', '--bundle', 'held_example.com']);
|
|
||||||
|
|
||||||
expect(h.out.join('\n')).toContain('seat: already held_example.com (pi)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('assigns every scaffolded seat with --all', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
scaffoldSeat(h.home, 'a');
|
|
||||||
scaffoldSeat(h.home, 'b', { schema: 1, harness: 'pi', bundle: 'primary' });
|
|
||||||
|
|
||||||
await h.run(['assign', '--all', '--bundle', 'shared_example.com']);
|
|
||||||
|
|
||||||
for (const name of ['a', 'b']) {
|
|
||||||
const written = JSON.parse(
|
|
||||||
await readFile(join(h.home, 'fleet', 'agents', name, 'profile.json'), 'utf8'),
|
|
||||||
) as Record<string, unknown>;
|
|
||||||
expect(written['bundle']).toBe('shared_example.com');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses an ambiguous target rather than guessing', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
scaffoldSeat(h.home, 'a');
|
|
||||||
|
|
||||||
await h.run(['assign', 'a', '--all', '--bundle', 'x']);
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('exactly one of');
|
|
||||||
|
|
||||||
process.exitCode = undefined;
|
|
||||||
h.err.length = 0;
|
|
||||||
await h.run(['assign', '--bundle', 'x']);
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('exactly one of');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('names the seat that does not exist', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
await h.run(['assign', 'ghost', '--bundle', 'x']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('no such fleet agent');
|
|
||||||
expect(h.err.join('')).toContain('mosaic fleet agent new ghost');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to rewrite a profile that is already invalid', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
// Re-serializing a broken profile would produce a file that looks repaired and still
|
|
||||||
// fails at launch, with the original damage no longer visible.
|
|
||||||
scaffoldSeat(h.home, 'broken', { schema: 1, harness: 'claude', nonsense: true });
|
|
||||||
|
|
||||||
await h.run(['assign', 'broken', '--bundle', 'x']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('unknown profile key "nonsense"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mosaic auth list', () => {
|
|
||||||
it('says where bundles would live on a host that has none', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
await h.run(['list']);
|
|
||||||
|
|
||||||
expect(h.out.join('\n')).toContain(join(h.home, 'auth'));
|
|
||||||
expect(h.out.join('\n')).toContain('mosaic auth enroll');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows each bundle with its enrolment state and account', async () => {
|
|
||||||
const h = await harness({ runLogin: goodLogin('[email protected]') });
|
|
||||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
|
||||||
h.out.length = 0;
|
|
||||||
|
|
||||||
await h.run(['list', '--harness', 'claude']);
|
|
||||||
|
|
||||||
const text = h.out.join('\n');
|
|
||||||
expect(text).toContain('jason_woltje.com');
|
|
||||||
expect(text).toContain('enrolled');
|
|
||||||
expect(text).toContain('[email protected]');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mosaic auth default', () => {
|
|
||||||
it('moves the primary alias to a bundle', async () => {
|
|
||||||
const h = await harness({ runLogin: goodLogin('[email protected]') });
|
|
||||||
await h.run(['enroll', '--harness', 'claude', '--bundle', 'jason_woltje.com']);
|
|
||||||
h.out.length = 0;
|
|
||||||
|
|
||||||
await h.run(['default', 'jason_woltje.com', '--harness', 'claude']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
expect(h.out.join('\n')).toContain('primary -> jason_woltje.com');
|
|
||||||
h.out.length = 0;
|
|
||||||
await h.run(['list', '--harness', 'claude']);
|
|
||||||
expect(h.out.join('\n')).toContain('primary -> jason_woltje.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a bundle that was never enrolled', async () => {
|
|
||||||
const h = await harness();
|
|
||||||
mkdirSync(join(h.home, 'auth', 'claude'), { recursive: true });
|
|
||||||
|
|
||||||
await h.run(['default', 'missing_example.com', '--harness', 'claude']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(h.err.join('')).toContain('no such bundle');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
/**
|
|
||||||
* `mosaic auth enroll | assign | list | default` -- the operator surface for credential bundles.
|
|
||||||
*
|
|
||||||
* These are local commands. They never talk to the gateway, unlike the rest of `mosaic auth`,
|
|
||||||
* and they work on a host where the gateway is down. What they do is give one host more than
|
|
||||||
* one account per harness and let each seat be pegged to one of them.
|
|
||||||
*
|
|
||||||
* Enroll does not reimplement any harness's login. It creates a private bundle directory,
|
|
||||||
* points the harness's own home at it by environment, and runs the harness. Whatever the
|
|
||||||
* harness writes is then checked: credential present, owner-only, and the account it belongs
|
|
||||||
* to recorded. Logging into the wrong account is the failure this catches -- it is otherwise
|
|
||||||
* silent, and it collapses two principals back into one.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import type { Command } from 'commander';
|
|
||||||
import {
|
|
||||||
AuthBundleError,
|
|
||||||
PRIMARY_ALIAS,
|
|
||||||
completeEnrollment,
|
|
||||||
listBundles,
|
|
||||||
prepareEnrollment,
|
|
||||||
setDefaultBundle,
|
|
||||||
} from '../fleet/auth-bundles.js';
|
|
||||||
import type { CredentialHarness } from '../fleet/credential-sharing.js';
|
|
||||||
import { defaultFleetDataHome } from '../fleet/fleet-agent-scaffold.js';
|
|
||||||
import { FleetLaunchError, parseFleetAgentProfile } from './fleet-launch-command.js';
|
|
||||||
|
|
||||||
const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi'];
|
|
||||||
|
|
||||||
export interface FleetAuthCommandDeps {
|
|
||||||
/** Test seam for the user-owned ~/.mosaic root. */
|
|
||||||
readonly fleetDataHome?: string;
|
|
||||||
/**
|
|
||||||
* Test seam for running the harness login. Returns the harness's exit status; `null` means
|
|
||||||
* the harness could not be started at all.
|
|
||||||
*/
|
|
||||||
readonly runLogin?: (
|
|
||||||
command: string,
|
|
||||||
args: readonly string[],
|
|
||||||
env: Readonly<Record<string, string>>,
|
|
||||||
) => number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireHarness(value: string | undefined): CredentialHarness {
|
|
||||||
if (value === undefined || !HARNESSES.includes(value as CredentialHarness)) {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'invalid-request',
|
|
||||||
`--harness must be one of: ${HARNESSES.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value as CredentialHarness;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultRunLogin(
|
|
||||||
command: string,
|
|
||||||
args: readonly string[],
|
|
||||||
env: Readonly<Record<string, string>>,
|
|
||||||
): number | null {
|
|
||||||
const result = spawnSync(command, [...args], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
env: { ...process.env, ...env },
|
|
||||||
});
|
|
||||||
if (result.error !== undefined) return null;
|
|
||||||
return result.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fail(error: unknown, verb: string): void {
|
|
||||||
process.exitCode = 1;
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
const code =
|
|
||||||
error instanceof AuthBundleError
|
|
||||||
? error.code
|
|
||||||
: error instanceof FleetLaunchError
|
|
||||||
? error.code
|
|
||||||
: 'failed';
|
|
||||||
process.stderr.write(`mosaic auth ${verb} failed (${code}): ${message}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── assign ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface AssignOutcome {
|
|
||||||
readonly agent: string;
|
|
||||||
readonly harness: CredentialHarness;
|
|
||||||
readonly from: string;
|
|
||||||
readonly to: string;
|
|
||||||
readonly changed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function agentsRoot(dataHome: string): string {
|
|
||||||
return join(dataHome, 'fleet', 'agents');
|
|
||||||
}
|
|
||||||
|
|
||||||
function listAgents(dataHome: string): string[] {
|
|
||||||
try {
|
|
||||||
return readdirSync(agentsRoot(dataHome), { withFileTypes: true })
|
|
||||||
.filter((entry) => entry.isDirectory())
|
|
||||||
.map((entry) => entry.name)
|
|
||||||
.sort();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrite one seat's `bundle`, leaving every other field byte-identical where possible.
|
|
||||||
*
|
|
||||||
* The profile is re-parsed before writing rather than patched blind: an already-invalid
|
|
||||||
* profile should be reported as invalid here, not silently re-serialized into something that
|
|
||||||
* looks fine and still fails at launch.
|
|
||||||
*/
|
|
||||||
function assignOne(dataHome: string, agent: string, bundle: string): AssignOutcome {
|
|
||||||
const path = join(agentsRoot(dataHome), agent, 'profile.json');
|
|
||||||
let source: string;
|
|
||||||
try {
|
|
||||||
source = readFileSync(path, 'utf8');
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'invalid-request',
|
|
||||||
`no such fleet agent: ${path} — scaffold it first: mosaic fleet agent new ${agent}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const profile = parseFleetAgentProfile(source);
|
|
||||||
const harness = profile.harness as CredentialHarness;
|
|
||||||
const raw = JSON.parse(source) as Record<string, unknown>;
|
|
||||||
const from = profile.bundle;
|
|
||||||
if (from === bundle) return { agent, harness, from, to: bundle, changed: false };
|
|
||||||
raw['bundle'] = bundle;
|
|
||||||
writeFileSync(path, `${JSON.stringify(raw, null, 2)}\n`);
|
|
||||||
return { agent, harness, from, to: bundle, changed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── registration ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Adds the local bundle verbs onto the existing `mosaic auth` command. */
|
|
||||||
export function registerFleetAuthCommands(
|
|
||||||
authCommand: Command,
|
|
||||||
deps: FleetAuthCommandDeps = {},
|
|
||||||
): void {
|
|
||||||
const dataHome = (): string => deps.fleetDataHome ?? defaultFleetDataHome();
|
|
||||||
const runLogin = deps.runLogin ?? defaultRunLogin;
|
|
||||||
|
|
||||||
authCommand
|
|
||||||
.command('enroll')
|
|
||||||
.description('Enrol a credential bundle by running a harness login into a private directory')
|
|
||||||
.requiredOption('--harness <harness>', `Harness: ${HARNESSES.join(', ')}`)
|
|
||||||
.requiredOption('--bundle <bundle>', 'Bundle name, normally the account email with @ as _')
|
|
||||||
.option('--login-arg <arg...>', 'Arguments to pass to the harness login invocation')
|
|
||||||
.option('--no-login', 'Only create the bundle directory; run the login yourself')
|
|
||||||
.action(
|
|
||||||
(options: {
|
|
||||||
harness?: string;
|
|
||||||
bundle: string;
|
|
||||||
loginArg?: string[];
|
|
||||||
login?: boolean;
|
|
||||||
}): void => {
|
|
||||||
try {
|
|
||||||
const harness = requireHarness(options.harness);
|
|
||||||
const plan = prepareEnrollment(dataHome(), harness, options.bundle);
|
|
||||||
|
|
||||||
console.log(`Bundle directory: ${plan.bundleDir}`);
|
|
||||||
if (plan.hadCredential) {
|
|
||||||
console.log('A credential is already present. Logging in again replaces it.');
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(plan.env)) {
|
|
||||||
console.log(` ${key}=${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.login === false) {
|
|
||||||
console.log(
|
|
||||||
`\nRun the ${harness} login with the environment above, then verify with:\n mosaic auth list --harness ${harness}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`\nStarting ${harness} against that directory. Complete the login inside it, then exit.`,
|
|
||||||
);
|
|
||||||
const status = runLogin(harness, options.loginArg ?? [], plan.env);
|
|
||||||
if (status === null) {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'invalid-request',
|
|
||||||
`could not start "${harness}" — is it installed and on PATH?`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// A non-zero login is reported but still checked: some harnesses exit non-zero on
|
|
||||||
// a normal quit after a successful login, and the credential on disk is the fact
|
|
||||||
// that matters, not the exit status.
|
|
||||||
if (status !== 0) {
|
|
||||||
console.log(`\nNote: ${harness} exited ${String(status)}. Checking the bundle anyway.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = completeEnrollment(plan);
|
|
||||||
console.log(`\nEnrolled ${harness} bundle "${result.bundle}".`);
|
|
||||||
console.log(` credential: ${result.credentialPath}`);
|
|
||||||
if (result.tightened) {
|
|
||||||
console.log(' permissions: tightened to owner-only');
|
|
||||||
}
|
|
||||||
if (result.email !== undefined) {
|
|
||||||
console.log(` account: ${result.email}`);
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
' account: could not be determined from what the harness wrote; the bundle name is not verified against the logged-in account',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (result.identityMismatch !== undefined) {
|
|
||||||
process.exitCode = 1;
|
|
||||||
process.stderr.write(
|
|
||||||
`\nWARNING: this bundle is named "${result.bundle}" but the account that logged in is "${result.email ?? 'unknown'}", which implies "${result.identityMismatch}".\n` +
|
|
||||||
'Two seats pointed at bundles that hold the same account are one principal wearing two names. Re-enrol under the right name, or delete this bundle.\n',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
`\nAssign it to a seat with:\n mosaic auth assign <agent> --bundle ${result.bundle}`,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
fail(error, 'enroll');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
authCommand
|
|
||||||
.command('assign [agent]')
|
|
||||||
.description('Peg a fleet seat to a credential bundle')
|
|
||||||
.requiredOption('--bundle <bundle>', 'Bundle name to assign')
|
|
||||||
.option('--all', 'Assign every scaffolded seat')
|
|
||||||
.action((agent: string | undefined, options: { bundle: string; all?: boolean }): void => {
|
|
||||||
try {
|
|
||||||
const home = dataHome();
|
|
||||||
if ((agent === undefined) === (options.all !== true)) {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'invalid-request',
|
|
||||||
'give exactly one of: an agent name, or --all',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const targets = options.all === true ? listAgents(home) : [agent as string];
|
|
||||||
if (targets.length === 0) {
|
|
||||||
console.log('No scaffolded fleet agents found; nothing to assign.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Assignment does not require the bundle to be enrolled -- scaffolding a seat before
|
|
||||||
// its account exists is a normal order of operations -- but an unenrolled bundle is
|
|
||||||
// worth saying out loud, because the seat will refuse to launch until it is. The
|
|
||||||
// check is per harness: the same bundle name under a different harness is a
|
|
||||||
// different bundle.
|
|
||||||
const unenrolled = new Set<CredentialHarness>();
|
|
||||||
for (const target of targets) {
|
|
||||||
const outcome = assignOne(home, target, options.bundle);
|
|
||||||
console.log(
|
|
||||||
outcome.changed
|
|
||||||
? `${outcome.agent}: ${outcome.from} -> ${outcome.to} (${outcome.harness})`
|
|
||||||
: `${outcome.agent}: already ${outcome.to} (${outcome.harness})`,
|
|
||||||
);
|
|
||||||
const enrolled = listBundles(home, outcome.harness).some(
|
|
||||||
(entry) => entry.name === options.bundle && entry.enrolled,
|
|
||||||
);
|
|
||||||
if (!enrolled) unenrolled.add(outcome.harness);
|
|
||||||
}
|
|
||||||
for (const harness of unenrolled) {
|
|
||||||
console.log(
|
|
||||||
`\nNotice: "${options.bundle}" is not enrolled for ${harness}, so those seats will refuse to launch until it is.\n mosaic auth enroll --harness ${harness} --bundle ${options.bundle}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
fail(error, 'assign');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
authCommand
|
|
||||||
.command('list')
|
|
||||||
.description('List local credential bundles and which accounts they hold')
|
|
||||||
.option('--harness <harness>', `Limit to one harness: ${HARNESSES.join(', ')}`)
|
|
||||||
.action((options: { harness?: string }): void => {
|
|
||||||
try {
|
|
||||||
const home = dataHome();
|
|
||||||
const harnesses =
|
|
||||||
options.harness === undefined ? HARNESSES : [requireHarness(options.harness)];
|
|
||||||
let found = 0;
|
|
||||||
for (const harness of harnesses) {
|
|
||||||
const bundles = listBundles(home, harness);
|
|
||||||
if (bundles.length === 0) continue;
|
|
||||||
found += bundles.length;
|
|
||||||
console.log(`${harness}:`);
|
|
||||||
for (const bundle of bundles) {
|
|
||||||
const parts = [
|
|
||||||
bundle.alias ? `${bundle.name} -> ${bundle.target ?? '(dangling)'}` : bundle.name,
|
|
||||||
bundle.enrolled ? 'enrolled' : 'NOT ENROLLED',
|
|
||||||
];
|
|
||||||
if (bundle.email !== undefined) parts.push(bundle.email);
|
|
||||||
console.log(` ${parts.join(' ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found === 0) {
|
|
||||||
console.log(
|
|
||||||
`No credential bundles under ${join(home, 'auth')}.\nEnrol one with: mosaic auth enroll --harness <harness> --bundle <account>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
fail(error, 'list');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
authCommand
|
|
||||||
.command('default <bundle>')
|
|
||||||
.description(`Point the movable "${PRIMARY_ALIAS}" alias at a bundle`)
|
|
||||||
.requiredOption('--harness <harness>', `Harness: ${HARNESSES.join(', ')}`)
|
|
||||||
.action((bundle: string, options: { harness?: string }): void => {
|
|
||||||
try {
|
|
||||||
const harness = requireHarness(options.harness);
|
|
||||||
const alias = setDefaultBundle(dataHome(), harness, bundle);
|
|
||||||
console.log(`${alias} -> ${bundle}`);
|
|
||||||
console.log(
|
|
||||||
`Seats with "bundle": "${PRIMARY_ALIAS}" now use ${bundle} at their next launch. Seats pinned to a named bundle are unaffected.`,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
fail(error, 'default');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,892 +0,0 @@
|
|||||||
import {
|
|
||||||
chmodSync,
|
|
||||||
existsSync,
|
|
||||||
lstatSync,
|
|
||||||
mkdirSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readFileSync,
|
|
||||||
readlinkSync,
|
|
||||||
rmSync,
|
|
||||||
symlinkSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
applyFleetLaunchComposition,
|
|
||||||
deepMergeSettings,
|
|
||||||
FleetLaunchError,
|
|
||||||
formatFleetLaunchDryRun,
|
|
||||||
parseFleetAgentProfile,
|
|
||||||
registerFleetLaunchCommand,
|
|
||||||
resolveFleetLaunchComposition,
|
|
||||||
} from './fleet-launch-command.js';
|
|
||||||
|
|
||||||
const roots: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
function fixture(profile: Record<string, unknown> = { schema: 1, harness: 'claude' }): {
|
|
||||||
root: string;
|
|
||||||
systemHome: string;
|
|
||||||
userHome: string;
|
|
||||||
agentDir: string;
|
|
||||||
namedBundleDir: string;
|
|
||||||
credentialName: string;
|
|
||||||
} {
|
|
||||||
const harness = String(profile.harness ?? 'claude');
|
|
||||||
const credentialName = harness === 'claude' ? '.credentials.json' : 'auth.json';
|
|
||||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-fleet-launch-'));
|
|
||||||
roots.push(root);
|
|
||||||
const systemHome = join(root, 'system');
|
|
||||||
const userHome = join(root, 'user');
|
|
||||||
const agentDir = join(userHome, 'fleet', 'agents', 'fred');
|
|
||||||
const namedBundleDir = join(userHome, 'auth', harness, 'fred_example.com');
|
|
||||||
mkdirSync(join(systemHome, 'runtime', harness), { recursive: true });
|
|
||||||
mkdirSync(agentDir, { recursive: true });
|
|
||||||
mkdirSync(namedBundleDir, { recursive: true });
|
|
||||||
writeFileSync(join(systemHome, 'runtime', harness, 'settings.json'), '{}\n');
|
|
||||||
writeFileSync(join(agentDir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
|
|
||||||
writeFileSync(join(namedBundleDir, credentialName), '{}\n', { mode: 0o600 });
|
|
||||||
writeFileSync(
|
|
||||||
join(namedBundleDir, 'account.json'),
|
|
||||||
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
|
|
||||||
);
|
|
||||||
symlinkSync('fred_example.com', join(userHome, 'auth', harness, 'primary'), 'dir');
|
|
||||||
return { root, systemHome, userHome, agentDir, namedBundleDir, credentialName };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('fleet launch profile schema 1', () => {
|
|
||||||
it('rejects an unknown key and names it', () => {
|
|
||||||
expect(() =>
|
|
||||||
parseFleetAgentProfile('{"schema":1,"harness":"claude","pluigns":[]}'),
|
|
||||||
).toThrowError(/unknown profile key "pluigns"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses a dedicated SCHEMA_TOO_NEW error with an upgrade hint', () => {
|
|
||||||
try {
|
|
||||||
parseFleetAgentProfile('{"schema":2,"harness":"claude"}');
|
|
||||||
throw new Error('expected parse to fail');
|
|
||||||
} catch (error) {
|
|
||||||
expect(error).toBeInstanceOf(FleetLaunchError);
|
|
||||||
expect((error as FleetLaunchError).code).toBe('SCHEMA_TOO_NEW');
|
|
||||||
expect((error as Error).message).toMatch(/upgrade Mosaic/i);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('three-layer settings merge', () => {
|
|
||||||
it('keeps base-only settings', () => {
|
|
||||||
expect(deepMergeSettings({ base: { enabled: true } })).toEqual({ base: { enabled: true } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the last layer for scalar conflicts', () => {
|
|
||||||
expect(deepMergeSettings({ model: 'base' }, { model: 'user' })).toEqual({ model: 'user' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaces arrays instead of appending', () => {
|
|
||||||
expect(deepMergeSettings({ hooks: ['base'] }, { hooks: ['user'] })).toEqual({
|
|
||||||
hooks: ['user'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses null as a key-deleting tombstone', () => {
|
|
||||||
expect(
|
|
||||||
deepMergeSettings({ nested: { keep: true, remove: true } }, { nested: { remove: null } }),
|
|
||||||
).toEqual({ nested: { keep: true } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaces a hook event array wholesale with the higher layer', () => {
|
|
||||||
const qaStop = { hooks: [{ type: 'command', command: 'qa-stop.sh' }] };
|
|
||||||
const leaseStop = { hooks: [{ type: 'command', command: 'receipt-observer.py' }] };
|
|
||||||
const qaPre = { matcher: 'Write', hooks: [{ type: 'command', command: 'qa-pre.sh' }] };
|
|
||||||
expect(
|
|
||||||
deepMergeSettings(
|
|
||||||
{ hooks: { Stop: [qaStop], PreToolUse: [qaPre] } },
|
|
||||||
{ hooks: { Stop: [leaseStop] } },
|
|
||||||
),
|
|
||||||
).toEqual({ hooks: { Stop: [leaseStop], PreToolUse: [qaPre] } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reconstructs every gated hook event from the base and lease overlay', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', overlay: 'lease-overlay.json' });
|
|
||||||
const frameworkRuntime = join(process.cwd(), 'framework', 'runtime', 'claude');
|
|
||||||
writeFileSync(
|
|
||||||
join(fx.systemHome, 'runtime', 'claude', 'settings.json'),
|
|
||||||
readFileSync(join(frameworkRuntime, 'settings.json'), 'utf8'),
|
|
||||||
);
|
|
||||||
writeFileSync(
|
|
||||||
join(fx.agentDir, 'lease-overlay.json'),
|
|
||||||
readFileSync(join(frameworkRuntime, 'lease-overlay.json'), 'utf8'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(plan.settings.merged['hooks']).toEqual({
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
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: '~/.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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still deletes a whole hook event via the null tombstone', () => {
|
|
||||||
expect(
|
|
||||||
deepMergeSettings(
|
|
||||||
{ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'qa-stop.sh' }] }] } },
|
|
||||||
{ hooks: { Stop: null } },
|
|
||||||
),
|
|
||||||
).toEqual({ hooks: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaces an allowedCommands-shaped non-hook array wholesale', () => {
|
|
||||||
expect(
|
|
||||||
deepMergeSettings(
|
|
||||||
{ allowedCommands: ['pnpm', 'git'], nested: { hooks: { Stop: ['base'] } } },
|
|
||||||
{ allowedCommands: ['node'], nested: { hooks: { Stop: ['user'] } } },
|
|
||||||
),
|
|
||||||
).toEqual({ allowedCommands: ['node'], nested: { hooks: { Stop: ['user'] } } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deep-merges all three layers in precedence order', () => {
|
|
||||||
expect(
|
|
||||||
deepMergeSettings(
|
|
||||||
{ nested: { system: true, shared: 'system' }, list: [1] },
|
|
||||||
{ nested: { user: true, shared: 'user' }, list: [2] },
|
|
||||||
{ nested: { agent: true, shared: 'agent' }, list: [3] },
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
nested: { system: true, user: true, agent: true, shared: 'agent' },
|
|
||||||
list: [3],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('profile-selected overlay', () => {
|
|
||||||
it('defaults to no overlay when the optional profile field is omitted', () => {
|
|
||||||
const fx = fixture();
|
|
||||||
writeFileSync(join(fx.agentDir, 'overlay.json'), '{"mustNotLoad":true}\n');
|
|
||||||
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
expect(plan.settings.merged).toEqual({});
|
|
||||||
expect(plan.settings.layers[2]?.present).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('system settings layer on a real install', () => {
|
|
||||||
it('composes a harness whose runtime ships no settings.json', () => {
|
|
||||||
// Measured on a greenfield Debian 13 VM against framework main: the install ships
|
|
||||||
// runtime/<harness>/ for claude, codex, opencode and pi but a settings.json only for
|
|
||||||
// claude. Requiring the file made every pi seat unlaunchable.
|
|
||||||
const fx = fixture({ schema: 1, harness: 'pi' });
|
|
||||||
rmSync(join(fx.systemHome, 'runtime', 'pi', 'settings.json'));
|
|
||||||
writeFileSync(join(fx.systemHome, 'runtime', 'pi', 'RUNTIME.md'), '# pi\n');
|
|
||||||
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
expect(plan.settings.layers[0]?.present).toBe(false);
|
|
||||||
expect(plan.settings.merged).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still refuses a harness the framework does not carry', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'pi' });
|
|
||||||
rmSync(join(fx.systemHome, 'runtime', 'pi'), { recursive: true });
|
|
||||||
|
|
||||||
try {
|
|
||||||
resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
throw new Error('expected resolution to fail');
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const launchError = error as FleetLaunchError;
|
|
||||||
expect(launchError.code).toBe('COMPOSITION_FAILED');
|
|
||||||
expect(launchError.message).toMatch(/harness runtime is not installed/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('never-enrolled hosts', () => {
|
|
||||||
it('names the enroll command instead of reporting a shape violation', () => {
|
|
||||||
// A host that has simply never logged in has no ~/.mosaic/auth at all. Reusing the
|
|
||||||
// wrong-shape wording there told the operator their auth directory "must be a real,
|
|
||||||
// non-symlink directory", which reads as tampering rather than "enroll a bundle".
|
|
||||||
const fx = fixture();
|
|
||||||
rmSync(join(fx.userHome, 'auth'), { recursive: true });
|
|
||||||
|
|
||||||
try {
|
|
||||||
resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
throw new Error('expected resolution to fail');
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const launchError = error as FleetLaunchError;
|
|
||||||
expect(launchError.code).toBe('COMPOSITION_FAILED');
|
|
||||||
expect(launchError.message).toMatch(/does not exist/);
|
|
||||||
expect(launchError.message).toMatch(/mosaic auth enroll/);
|
|
||||||
expect(launchError.message).not.toMatch(/non-symlink/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('unscaffolded agent names', () => {
|
|
||||||
it('points an unscaffolded name at mosaic fleet agent new', () => {
|
|
||||||
const fx = fixture();
|
|
||||||
try {
|
|
||||||
resolveFleetLaunchComposition('ghost', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
throw new Error('expected resolution to fail');
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const launchError = error as FleetLaunchError;
|
|
||||||
expect(launchError.code).toBe('AGENT_NOT_SCAFFOLDED');
|
|
||||||
expect(launchError.message).toContain("no such fleet agent 'ghost'");
|
|
||||||
expect(launchError.message).toContain('mosaic fleet agent new ghost');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('A3 credential validation', () => {
|
|
||||||
it('refuses a symlinked bundle credential file', () => {
|
|
||||||
const fx = fixture();
|
|
||||||
rmSync(join(fx.namedBundleDir, '.credentials.json'));
|
|
||||||
const outside = join(fx.root, 'outside-credentials.json');
|
|
||||||
writeFileSync(outside, '{}\n');
|
|
||||||
symlinkSync(outside, join(fx.namedBundleDir, '.credentials.json'));
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
}),
|
|
||||||
).toThrowError(/real, non-symlink credential file/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses an auth ancestor symlink that relocates the credential trust root', () => {
|
|
||||||
const fx = fixture();
|
|
||||||
rmSync(join(fx.userHome, 'auth'), { recursive: true, force: true });
|
|
||||||
const outsideAuth = join(fx.root, 'outside-auth');
|
|
||||||
const outsideBundle = join(outsideAuth, 'claude', 'fred_example.com');
|
|
||||||
mkdirSync(outsideBundle, { recursive: true });
|
|
||||||
writeFileSync(join(outsideBundle, '.credentials.json'), '{}\n', { mode: 0o600 });
|
|
||||||
writeFileSync(
|
|
||||||
join(outsideBundle, 'account.json'),
|
|
||||||
'{"oauthAccount":{"emailAddress":"[email protected]"}}\n',
|
|
||||||
);
|
|
||||||
symlinkSync('fred_example.com', join(outsideAuth, 'claude', 'primary'), 'dir');
|
|
||||||
symlinkSync(outsideAuth, join(fx.userHome, 'auth'), 'dir');
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
}),
|
|
||||||
).toThrowError(/auth directory must be a real, non-symlink directory/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a group- or world-readable credential file', () => {
|
|
||||||
const fx = fixture();
|
|
||||||
chmodSync(join(fx.namedBundleDir, '.credentials.json'), 0o644);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
}),
|
|
||||||
).toThrowError(/credential file must not grant group or other permissions/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a real private credential file contained in the harness auth root', () => {
|
|
||||||
const fx = fixture();
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(plan.credential.target).toBe(join(fx.namedBundleDir, '.credentials.json'));
|
|
||||||
expect(plan.bundle.display).toBe('primary -> fred_example.com ([email protected])');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses first-auth state when a real file occupies the seat link', () => {
|
|
||||||
const fx = fixture();
|
|
||||||
const seatHome = join(fx.agentDir, '.claude');
|
|
||||||
mkdirSync(seatHome, { recursive: true });
|
|
||||||
writeFileSync(join(seatHome, '.credentials.json'), '{"private":true}\n');
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
}),
|
|
||||||
).toThrowError(/first-auth.*refusing to delete or overwrite/i);
|
|
||||||
expect(lstatSync(join(seatHome, '.credentials.json')).isSymbolicLink()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('points Claude at the resolved bundle directory and plans no credential link', () => {
|
|
||||||
const fx = fixture();
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(plan.credential.link).toBeUndefined();
|
|
||||||
expect(plan.credential.dir).toBe(fx.namedBundleDir);
|
|
||||||
// An empty value resolves to ~/.claude, which is the operator's own account,
|
|
||||||
// so the exported value must always be the absolute bundle path.
|
|
||||||
expect(plan.env['CLAUDE_SECURESTORAGE_CONFIG_DIR']).toBe(fx.namedBundleDir);
|
|
||||||
expect(plan.env['CLAUDE_SECURESTORAGE_CONFIG_DIR']).not.toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the managed credential link for a harness with no credential-directory variable', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'pi' });
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(plan.credential.link).toBe(join(fx.agentDir, '.pi', 'auth.json'));
|
|
||||||
expect(plan.credential.target).toBe(join(fx.namedBundleDir, 'auth.json'));
|
|
||||||
expect(Object.keys(plan.env)).not.toContain('CLAUDE_SECURESTORAGE_CONFIG_DIR');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('managed plugin and skill links', () => {
|
|
||||||
it('refuses an unrecorded foreign symlink without mutating it', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', plugins: [] });
|
|
||||||
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
|
|
||||||
const foreign = join(fx.root, 'foreign-plugin');
|
|
||||||
mkdirSync(pluginHome, { recursive: true });
|
|
||||||
mkdirSync(foreign, { recursive: true });
|
|
||||||
symlinkSync(foreign, join(pluginHome, 'foreign'), 'dir');
|
|
||||||
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
|
||||||
/unrecorded or retargeted symlink/,
|
|
||||||
);
|
|
||||||
expect(readlinkSync(join(pluginHome, 'foreign'))).toBe(foreign);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('performs no writes when a late foreign install link is refused', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] });
|
|
||||||
const target = join(fx.userHome, 'plugins', 'keep');
|
|
||||||
const link = join(fx.agentDir, '.claude', 'plugins', 'keep');
|
|
||||||
mkdirSync(target, { recursive: true });
|
|
||||||
mkdirSync(join(link, '..'), { recursive: true });
|
|
||||||
writeFileSync(join(fx.agentDir, '.claude', '.mosaic-managed-links.json'), '{"links":{}}\n');
|
|
||||||
symlinkSync(target, link, 'dir');
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
const snapshot = join(fx.agentDir, 'settings.generated.json');
|
|
||||||
const temp = join(fx.agentDir, '.claude', '.mosaic-managed-links.json.tmp');
|
|
||||||
|
|
||||||
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
|
||||||
/unrecorded or retargeted symlink/,
|
|
||||||
);
|
|
||||||
expect(existsSync(snapshot)).toBe(false);
|
|
||||||
expect(existsSync(temp)).toBe(false);
|
|
||||||
expect(readlinkSync(link)).toBe(target);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prunes a recorded matching stale symlink', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] });
|
|
||||||
mkdirSync(join(fx.userHome, 'plugins', 'old'), { recursive: true });
|
|
||||||
const initial = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
applyFleetLaunchComposition(initial);
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
join(fx.agentDir, 'profile.json'),
|
|
||||||
'{"schema":1,"harness":"claude","plugins":[]}\n',
|
|
||||||
);
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
|
|
||||||
expect(plan.prune).toEqual([join(pluginHome, 'old')]);
|
|
||||||
applyFleetLaunchComposition(plan);
|
|
||||||
|
|
||||||
expect(() => lstatSync(join(pluginHome, 'old'))).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a recorded link retargeted after composition and leaves it intact', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['old'] });
|
|
||||||
const managedTarget = join(fx.userHome, 'plugins', 'old');
|
|
||||||
const foreignTarget = join(fx.root, 'foreign-plugin');
|
|
||||||
mkdirSync(managedTarget, { recursive: true });
|
|
||||||
mkdirSync(foreignTarget, { recursive: true });
|
|
||||||
const initial = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
applyFleetLaunchComposition(initial);
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
join(fx.agentDir, 'profile.json'),
|
|
||||||
'{"schema":1,"harness":"claude","plugins":[]}\n',
|
|
||||||
);
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
const link = join(fx.agentDir, '.claude', 'plugins', 'old');
|
|
||||||
rmSync(link);
|
|
||||||
symlinkSync(foreignTarget, link, 'dir');
|
|
||||||
|
|
||||||
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
|
||||||
/unrecorded or retargeted symlink/,
|
|
||||||
);
|
|
||||||
expect(readlinkSync(link)).toBe(foreignTarget);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a tampered manifest entry outside this seat and leaves it intact', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude' });
|
|
||||||
const seatHome = join(fx.agentDir, '.claude');
|
|
||||||
mkdirSync(seatHome, { recursive: true });
|
|
||||||
const manifest = join(seatHome, '.mosaic-managed-links.json');
|
|
||||||
const crossSeat = join(fx.userHome, 'fleet', 'agents', 'other', '.claude', 'plugins', 'keep');
|
|
||||||
writeFileSync(
|
|
||||||
manifest,
|
|
||||||
JSON.stringify({ links: { [crossSeat]: join(fx.userHome, 'plugins', 'keep') } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
resolveFleetLaunchComposition('fred', { systemHome: fx.systemHome, userHome: fx.userHome }),
|
|
||||||
).toThrowError(/escapes an approved seat\/store root/);
|
|
||||||
expect(readFileSync(manifest, 'utf8')).toContain(crossSeat);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a symlinked manifest temporary path without modifying its target', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] });
|
|
||||||
const target = join(fx.userHome, 'plugins', 'keep');
|
|
||||||
const sentinel = join(fx.root, 'sentinel.json');
|
|
||||||
mkdirSync(target, { recursive: true });
|
|
||||||
mkdirSync(join(fx.agentDir, '.claude'), { recursive: true });
|
|
||||||
writeFileSync(sentinel, 'unchanged\n', { mode: 0o600 });
|
|
||||||
symlinkSync(sentinel, join(fx.agentDir, '.claude', '.mosaic-managed-links.json.tmp'), 'file');
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => applyFleetLaunchComposition(plan)).toThrowError(/cannot be created exclusively/);
|
|
||||||
expect(readFileSync(sentinel, 'utf8')).toBe('unchanged\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Credential links exist only for harnesses that are not pointed at their bundle
|
|
||||||
// by environment, so the containment rules are exercised on one of those.
|
|
||||||
it('refuses an exact-target unrecorded credential symlink', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'pi' });
|
|
||||||
const seatHome = join(fx.agentDir, '.pi');
|
|
||||||
const link = join(seatHome, fx.credentialName);
|
|
||||||
mkdirSync(seatHome, { recursive: true });
|
|
||||||
symlinkSync(join(fx.namedBundleDir, fx.credentialName), link, 'file');
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
|
||||||
/unrecorded or retargeted symlink/,
|
|
||||||
);
|
|
||||||
expect(readlinkSync(link)).toBe(join(fx.namedBundleDir, fx.credentialName));
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['plugins', 'skills'] as const)(
|
|
||||||
'refuses an exact-target unrecorded %s symlink',
|
|
||||||
(kind) => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', [kind]: ['keep'] });
|
|
||||||
const target = join(fx.userHome, kind, 'keep');
|
|
||||||
const link = join(fx.agentDir, '.claude', kind, 'keep');
|
|
||||||
mkdirSync(target, { recursive: true });
|
|
||||||
mkdirSync(join(link, '..'), { recursive: true });
|
|
||||||
writeFileSync(join(fx.agentDir, '.claude', '.mosaic-managed-links.json'), '{"links":{}}\n');
|
|
||||||
symlinkSync(target, link, 'dir');
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
|
||||||
/unrecorded or retargeted symlink/,
|
|
||||||
);
|
|
||||||
expect(readlinkSync(link)).toBe(target);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('refuses an unrecorded mismatched credential symlink', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'pi' });
|
|
||||||
const seatHome = join(fx.agentDir, '.pi');
|
|
||||||
const foreignCredential = join(fx.root, 'foreign-credential.json');
|
|
||||||
mkdirSync(seatHome, { recursive: true });
|
|
||||||
writeFileSync(foreignCredential, '{}\n', { mode: 0o600 });
|
|
||||||
symlinkSync(foreignCredential, join(seatHome, fx.credentialName), 'file');
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => applyFleetLaunchComposition(plan)).toThrowError(
|
|
||||||
/unrecorded or retargeted symlink/,
|
|
||||||
);
|
|
||||||
expect(readFileSync(join(seatHome, fx.credentialName), 'utf8')).toBe('{}\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tolerates harness metadata files in the install root and still refuses real directories', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', plugins: [] });
|
|
||||||
const pluginHome = join(fx.agentDir, '.claude', 'plugins');
|
|
||||||
mkdirSync(pluginHome, { recursive: true });
|
|
||||||
writeFileSync(join(pluginHome, 'installed_plugins.json'), '{}\n');
|
|
||||||
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
expect(plan.prune).toEqual([]);
|
|
||||||
expect(readFileSync(join(pluginHome, 'installed_plugins.json'), 'utf8')).toBe('{}\n');
|
|
||||||
|
|
||||||
mkdirSync(join(pluginHome, 'stray-plugin'), { recursive: true });
|
|
||||||
expect(() =>
|
|
||||||
resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
}),
|
|
||||||
).toThrowError(/real plugin directory occupies managed install root.*refusing to prune/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces a real directory at a managed link path without deleting it', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', plugins: ['keep'] });
|
|
||||||
mkdirSync(join(fx.userHome, 'plugins', 'keep'), { recursive: true });
|
|
||||||
const occupied = join(fx.agentDir, '.claude', 'plugins', 'keep');
|
|
||||||
mkdirSync(occupied, { recursive: true });
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
}),
|
|
||||||
).toThrowError(/real plugin directory.*refusing to delete/i);
|
|
||||||
expect(lstatSync(occupied).isDirectory()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fleet launch command outcomes', () => {
|
|
||||||
it('--dry-run prints without writing or invoking the launcher', () => {
|
|
||||||
const fx = fixture();
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
const fleet = program.command('fleet');
|
|
||||||
const launcher = vi.fn();
|
|
||||||
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
||||||
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
|
|
||||||
userHome: fx.userHome,
|
|
||||||
launcher,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--dry-run']);
|
|
||||||
expect(stdout).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('mosaic fleet launch fred --dry-run'),
|
|
||||||
);
|
|
||||||
expect(launcher).not.toHaveBeenCalled();
|
|
||||||
expect(() => lstatSync(join(fx.agentDir, '.claude'))).toThrow();
|
|
||||||
} finally {
|
|
||||||
stdout.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies the plan and invokes the existing launch seam with declared values', () => {
|
|
||||||
const fx = fixture({
|
|
||||||
schema: 1,
|
|
||||||
harness: 'claude',
|
|
||||||
model: 'opus',
|
|
||||||
env: { SEAT_FLAG: 'yes' },
|
|
||||||
});
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
const fleet = program.command('fleet');
|
|
||||||
const launcher = vi.fn();
|
|
||||||
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
|
|
||||||
userHome: fx.userHome,
|
|
||||||
launcher,
|
|
||||||
});
|
|
||||||
|
|
||||||
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
|
|
||||||
|
|
||||||
expect(launcher).toHaveBeenCalledWith(
|
|
||||||
'claude',
|
|
||||||
['--model', 'opus'],
|
|
||||||
{
|
|
||||||
CLAUDE_CONFIG_DIR: join(fx.agentDir, '.claude'),
|
|
||||||
CLAUDE_SECURESTORAGE_CONFIG_DIR: fx.namedBundleDir,
|
|
||||||
MOSAIC_AGENT_NAME: 'fred',
|
|
||||||
SEAT_FLAG: 'yes',
|
|
||||||
},
|
|
||||||
{ agentDir: fx.agentDir, mosaicHome: fx.systemHome },
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// The bundle is reached by environment, so nothing is planted at the seat path.
|
|
||||||
expect(existsSync(join(fx.agentDir, '.claude', '.credentials.json'))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('asks for dangerous permissions only when the caller does', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude' });
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
const fleet = program.command('fleet');
|
|
||||||
const launcher = vi.fn();
|
|
||||||
registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome, launcher });
|
|
||||||
|
|
||||||
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--dangerous']);
|
|
||||||
|
|
||||||
expect(launcher).toHaveBeenCalledWith('claude', [], expect.anything(), expect.anything(), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets a caller-supplied --model replace the profile model instead of duplicating it', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', model: 'opus' });
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
const fleet = program.command('fleet');
|
|
||||||
const launcher = vi.fn();
|
|
||||||
registerFleetLaunchCommand(fleet, () => fx.systemHome, { userHome: fx.userHome, launcher });
|
|
||||||
|
|
||||||
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred', '--model', 'sonnet']);
|
|
||||||
|
|
||||||
expect(launcher).toHaveBeenCalledWith(
|
|
||||||
'claude',
|
|
||||||
['--model', 'sonnet'],
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets a non-zero exit code and never invokes the launcher', () => {
|
|
||||||
const fx = fixture({ schema: 1, harness: 'claude', unknown: true });
|
|
||||||
const program = new Command().exitOverride();
|
|
||||||
const fleet = program.command('fleet');
|
|
||||||
const launcher = vi.fn();
|
|
||||||
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
||||||
const priorExitCode = process.exitCode;
|
|
||||||
process.exitCode = 0;
|
|
||||||
registerFleetLaunchCommand(fleet, () => fx.systemHome, {
|
|
||||||
userHome: fx.userHome,
|
|
||||||
launcher,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
program.parse(['node', 'mosaic', 'fleet', 'launch', 'fred']);
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(launcher).not.toHaveBeenCalled();
|
|
||||||
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('unknown profile key "unknown"'));
|
|
||||||
} finally {
|
|
||||||
process.exitCode = priorExitCode;
|
|
||||||
stderr.mockRestore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dry-run composition', () => {
|
|
||||||
it('renders a deterministic full composition and writes nothing', () => {
|
|
||||||
const fx = fixture({
|
|
||||||
schema: 1,
|
|
||||||
harness: 'claude',
|
|
||||||
bundle: 'primary',
|
|
||||||
model: 'opus',
|
|
||||||
overlay: 'overlay.json',
|
|
||||||
plugins: ['code-review'],
|
|
||||||
skills: ['mosaic-tools'],
|
|
||||||
env: { SEAT_FLAG: 'yes' },
|
|
||||||
});
|
|
||||||
writeFileSync(
|
|
||||||
join(fx.systemHome, 'runtime', 'claude', 'settings.json'),
|
|
||||||
'{"theme":"dark","hooks":["system"],"nested":{"system":true}}\n',
|
|
||||||
);
|
|
||||||
mkdirSync(join(fx.userHome, 'config', 'claude'), { recursive: true });
|
|
||||||
writeFileSync(
|
|
||||||
join(fx.userHome, 'config', 'claude', 'settings.json'),
|
|
||||||
'{"hooks":["user"],"nested":{"user":true}}\n',
|
|
||||||
);
|
|
||||||
writeFileSync(join(fx.agentDir, 'overlay.json'), '{"theme":null,"nested":{"agent":true}}\n');
|
|
||||||
mkdirSync(join(fx.userHome, 'plugins', 'code-review'), { recursive: true });
|
|
||||||
mkdirSync(join(fx.userHome, 'skills', 'mosaic-tools'), { recursive: true });
|
|
||||||
|
|
||||||
const plan = resolveFleetLaunchComposition('fred', {
|
|
||||||
systemHome: fx.systemHome,
|
|
||||||
userHome: fx.userHome,
|
|
||||||
});
|
|
||||||
const output = formatFleetLaunchDryRun(plan).replaceAll(fx.root, '<ROOT>');
|
|
||||||
|
|
||||||
expect(output).toMatchInlineSnapshot(`
|
|
||||||
"mosaic fleet launch fred --dry-run
|
|
||||||
profile: <ROOT>/user/fleet/agents/fred/profile.json (schema 1)
|
|
||||||
harness: claude
|
|
||||||
seat-home: <ROOT>/user/fleet/agents/fred/.claude
|
|
||||||
settings sources:
|
|
||||||
system: <ROOT>/system/runtime/claude/settings.json
|
|
||||||
user: <ROOT>/user/config/claude/settings.json
|
|
||||||
agent: <ROOT>/user/fleet/agents/fred/overlay.json
|
|
||||||
output: <ROOT>/user/fleet/agents/fred/.claude/settings.json
|
|
||||||
snapshot: <ROOT>/user/fleet/agents/fred/settings.generated.json
|
|
||||||
merged settings:
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
"user"
|
|
||||||
],
|
|
||||||
"nested": {
|
|
||||||
"agent": true,
|
|
||||||
"system": true,
|
|
||||||
"user": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bundle: primary -> fred_example.com ([email protected])
|
|
||||||
credential: <ROOT>/user/auth/claude/fred_example.com/.credentials.json
|
|
||||||
symlinks:
|
|
||||||
plugin code-review: <ROOT>/user/fleet/agents/fred/.claude/plugins/code-review -> <ROOT>/user/plugins/code-review
|
|
||||||
skill mosaic-tools: <ROOT>/user/fleet/agents/fred/.claude/skills/mosaic-tools -> <ROOT>/user/skills/mosaic-tools
|
|
||||||
declared env:
|
|
||||||
CLAUDE_CONFIG_DIR=<ROOT>/user/fleet/agents/fred/.claude
|
|
||||||
CLAUDE_SECURESTORAGE_CONFIG_DIR=<ROOT>/user/auth/claude/fred_example.com
|
|
||||||
MOSAIC_AGENT_NAME=fred
|
|
||||||
SEAT_FLAG=yes
|
|
||||||
argv: ["claude","--model","opus"]"
|
|
||||||
`);
|
|
||||||
expect(() => readFileSync(join(fx.agentDir, '.claude', 'settings.json'), 'utf8')).toThrow();
|
|
||||||
|
|
||||||
applyFleetLaunchComposition(plan);
|
|
||||||
expect(JSON.parse(readFileSync(plan.settings.output, 'utf8'))).toEqual({
|
|
||||||
hooks: ['user'],
|
|
||||||
nested: { agent: true, system: true, user: true },
|
|
||||||
});
|
|
||||||
expect(readFileSync(plan.settings.snapshot, 'utf8')).toBe(
|
|
||||||
readFileSync(plan.settings.output, 'utf8'),
|
|
||||||
);
|
|
||||||
expect(plan.credential.link).toBeUndefined();
|
|
||||||
expect(plan.credential.dir).toBe(fx.namedBundleDir);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -82,8 +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',
|
||||||
'adopt',
|
|
||||||
'agent',
|
|
||||||
'apply',
|
'apply',
|
||||||
'backlog',
|
'backlog',
|
||||||
'create',
|
'create',
|
||||||
@@ -93,7 +91,6 @@ describe('registerFleetCommand', () => {
|
|||||||
'init',
|
'init',
|
||||||
'install',
|
'install',
|
||||||
'install-systemd',
|
'install-systemd',
|
||||||
'launch',
|
|
||||||
'migrate-v1',
|
'migrate-v1',
|
||||||
'persona',
|
'persona',
|
||||||
'plan',
|
'plan',
|
||||||
|
|||||||
@@ -38,11 +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 { registerFleetAdoptCommand } from './fleet-adopt-command.js';
|
|
||||||
import {
|
import {
|
||||||
registerFleetMigrationCommand,
|
registerFleetMigrationCommand,
|
||||||
type FleetMigrationCommandDeps,
|
type FleetMigrationCommandDeps,
|
||||||
@@ -68,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).
|
||||||
@@ -103,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.
|
||||||
@@ -114,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'>;
|
||||||
}
|
}
|
||||||
@@ -2053,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);
|
||||||
@@ -2074,18 +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,
|
|
||||||
});
|
|
||||||
// The counterpart to launch's refusals: launch will not delete a real directory sitting on
|
|
||||||
// a managed path, and this is how one gets moved out of the way instead.
|
|
||||||
registerFleetAdoptCommand(cmd, {
|
|
||||||
...(deps.fleetDataHome === undefined ? {} : { fleetDataHome: deps.fleetDataHome }),
|
|
||||||
});
|
|
||||||
// Roster-v2 desired-state mutations belong directly to the fleet control
|
// Roster-v2 desired-state mutations belong directly to the fleet control
|
||||||
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
||||||
registerFleetAgentCrudCommands(cmd, deps);
|
registerFleetAgentCrudCommands(cmd, deps);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
launchFleetRuntimeForTest,
|
|
||||||
type RuntimeLaunchHandler,
|
type RuntimeLaunchHandler,
|
||||||
type ClaudexLaunchHandler,
|
type ClaudexLaunchHandler,
|
||||||
} from './launch.js';
|
} from './launch.js';
|
||||||
@@ -99,285 +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,
|
|
||||||
);
|
|
||||||
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
|
|
||||||
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);
|
|
||||||
const final = vi.fn((): never => {
|
|
||||||
throw new Error('final runtime boundary');
|
|
||||||
});
|
|
||||||
expect(() =>
|
|
||||||
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, final),
|
|
||||||
).toThrow('final runtime boundary');
|
|
||||||
expect(final).toHaveBeenCalledOnce();
|
|
||||||
} 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'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// Scaffolded seat, unconfigured harness: the seat's own identity is present so this
|
|
||||||
// still fails on the missing MCP configuration rather than on a missing SOUL.md.
|
|
||||||
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
|
|
||||||
vi.stubEnv('HOME', home);
|
|
||||||
expect(() =>
|
|
||||||
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
|
|
||||||
throw new Error('must not execute');
|
|
||||||
}),
|
|
||||||
).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,
|
|
||||||
);
|
|
||||||
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
|
|
||||||
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 });
|
|
||||||
writeFileSync(join(agentDir, 'SOUL.md'), '# SOUL\n');
|
|
||||||
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')],
|
|
||||||
{
|
|
||||||
env,
|
|
||||||
},
|
|
||||||
).status,
|
|
||||||
).toBe(0);
|
|
||||||
expect(
|
|
||||||
spawnSync(
|
|
||||||
checker,
|
|
||||||
['--check', '--runtime', 'claude', '--claude-config-dir', join(agentDir, '.claude')],
|
|
||||||
{ 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('refuses an unscaffolded seat instead of opening the interactive setup wizard', () => {
|
|
||||||
// Measured on a greenfield VM: a roster-started seat whose host had no system SOUL.md
|
|
||||||
// reached checkSoul(), which spawns `mosaic wizard` with inherited stdio. With nobody at
|
|
||||||
// the pane the seat parked on the wizard's menu -- tmux session live, unit reporting
|
|
||||||
// fine, no agent ever launched. A fleet seat's identity is its own SOUL.md, and an
|
|
||||||
// unattended launch must fail loudly rather than wait for a keystroke.
|
|
||||||
const home = mkdtempSync(join(tmpdir(), 'mosaic-soul-home-'));
|
|
||||||
const agentDir = mkdtempSync(join(tmpdir(), 'mosaic-soul-seat-'));
|
|
||||||
const installed = mkdtempSync(join(tmpdir(), 'mosaic-soul-installed-'));
|
|
||||||
const exit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
|
||||||
const error = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
||||||
try {
|
|
||||||
vi.stubEnv('HOME', home);
|
|
||||||
expect(() =>
|
|
||||||
launchFleetRuntimeForTest('claude', [], {}, { agentDir, mosaicHome: installed }, () => {
|
|
||||||
throw new Error('must not execute');
|
|
||||||
}),
|
|
||||||
).toThrow('process.exit called');
|
|
||||||
expect(exit).toHaveBeenCalledWith(1);
|
|
||||||
expect(error).toHaveBeenCalledWith(expect.stringContaining(join(agentDir, 'SOUL.md')));
|
|
||||||
expect(error).toHaveBeenCalledWith(expect.stringContaining('mosaic fleet agent new'));
|
|
||||||
} finally {
|
|
||||||
error.mockRestore();
|
|
||||||
exit.mockRestore();
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
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([
|
||||||
|
|||||||
@@ -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 { isAbsolute, join, dirname, relative, resolve, sep } 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 };
|
||||||
}
|
}
|
||||||
@@ -170,13 +162,7 @@ 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 {
|
||||||
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
mkdirSync(LAUNCH_LEDGER_DIR, { recursive: true, mode: 0o700 });
|
||||||
// Correlation id for the lease.register half. Set into process.env so it
|
// Correlation id for the lease.register half. Set into process.env so it
|
||||||
@@ -194,13 +180,13 @@ 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),
|
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(),
|
||||||
};
|
};
|
||||||
@@ -244,22 +230,7 @@ function checkRuntime(cmd: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkSoul(fleet?: FleetHarnessContext): void {
|
function checkSoul(): void {
|
||||||
// A fleet seat carries its own identity -- `mosaic fleet agent new` writes SOUL.md into the
|
|
||||||
// seat home -- so the operator's system-wide SOUL.md is not the file to check, and the
|
|
||||||
// interactive wizard is never the right answer for an unattended seat. Measured on a
|
|
||||||
// greenfield VM: a seat launched into tmux parked on the wizard's menu with nobody at the
|
|
||||||
// pane. The session was live, the unit reported fine, and no agent ever started.
|
|
||||||
if (fleet) {
|
|
||||||
const seatSoul = join(fleet.agentDir, 'SOUL.md');
|
|
||||||
if (!existsSync(seatSoul)) {
|
|
||||||
console.error(`[mosaic] ERROR: seat identity not found: ${seatSoul}`);
|
|
||||||
console.error('[mosaic] Scaffold the seat first: mosaic fleet agent new <name>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
const soulPath = join(MOSAIC_HOME, 'SOUL.md');
|
||||||
if (!existsSync(soulPath)) {
|
if (!existsSync(soulPath)) {
|
||||||
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
|
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
|
||||||
@@ -291,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) {
|
||||||
@@ -361,98 +332,13 @@ function printSettingsWarnings(audit: SettingsAudit): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveExecutable(name: string): string {
|
function checkSequentialThinking(runtime: string): void {
|
||||||
const result = spawnSync('which', [name], { encoding: 'utf8' });
|
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||||
const path = result.status === 0 ? result.stdout.trim() : '';
|
|
||||||
if (!path || !isAbsolute(path) || !existsSync(path)) {
|
|
||||||
throw new Error(`required helper executable is unavailable: ${name}`);
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function trustedFleetHelper(mosaicHome: string): string {
|
|
||||||
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)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return checker;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
const checker = fleet?.mosaicHome
|
|
||||||
? trustedFleetHelper(fleet.mosaicHome)
|
|
||||||
: fwScript('mosaic-ensure-sequential-thinking');
|
|
||||||
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
||||||
const fleetClaudeConfig =
|
const result = spawnSync(checker, ['--check', '--runtime', runtime], { stdio: 'ignore' });
|
||||||
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 python = resolveExecutable('python3');
|
|
||||||
const node = resolveExecutable('node');
|
|
||||||
const npx = resolveExecutable('npx');
|
|
||||||
const capabilityPath = [...new Set([dirname(python), dirname(node), dirname(npx)])].join(':');
|
|
||||||
const result = spawnSync(
|
|
||||||
checker,
|
|
||||||
[
|
|
||||||
'--check',
|
|
||||||
'--runtime',
|
|
||||||
runtime,
|
|
||||||
...(fleetClaudeConfig === undefined ? [] : ['--claude-config-dir', fleetClaudeConfig]),
|
|
||||||
],
|
|
||||||
{
|
|
||||||
stdio: 'ignore',
|
|
||||||
env: {
|
|
||||||
HOME: process.env['HOME'] ?? '',
|
|
||||||
PATH: capabilityPath,
|
|
||||||
LANG: process.env['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}`;
|
|
||||||
console.error(`[mosaic] Fix: ${checker} --runtime ${runtime}${repairArgs}`);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,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'),
|
||||||
@@ -658,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.`,
|
||||||
@@ -701,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);
|
||||||
|
|
||||||
@@ -731,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 ────────────────────────────────────────────────────────────
|
||||||
@@ -813,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) {
|
||||||
@@ -1011,51 +889,15 @@ 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 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;
|
|
||||||
readonly recordLaunch?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function minimalLaunchEnv(declared: Readonly<Record<string, string>>): NodeJS.ProcessEnv {
|
|
||||||
const env: NodeJS.ProcessEnv = {};
|
|
||||||
for (const name of [
|
|
||||||
'PATH',
|
|
||||||
'HOME',
|
|
||||||
'USER',
|
|
||||||
'LOGNAME',
|
|
||||||
'SHELL',
|
|
||||||
'TERM',
|
|
||||||
'COLORTERM',
|
|
||||||
'LANG',
|
|
||||||
'LC_ALL',
|
|
||||||
'TMPDIR',
|
|
||||||
'XDG_RUNTIME_DIR',
|
|
||||||
]) {
|
|
||||||
const value = process.env[name];
|
|
||||||
if (value !== undefined) env[name] = value;
|
|
||||||
}
|
|
||||||
return { ...env, ...declared };
|
|
||||||
}
|
|
||||||
|
|
||||||
function launchRuntime(
|
|
||||||
runtime: RuntimeName,
|
|
||||||
args: string[],
|
|
||||||
yolo: boolean,
|
|
||||||
context: RuntimeLaunchContext = {},
|
|
||||||
): never {
|
|
||||||
checkMosaicHome();
|
checkMosaicHome();
|
||||||
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
checkFile(join(MOSAIC_HOME, 'AGENTS.md'), 'AGENTS.md');
|
||||||
checkSoul(context.fleet);
|
checkSoul();
|
||||||
(context.runtimeCheck ?? 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();
|
||||||
@@ -1067,32 +909,14 @@ function launchRuntime(
|
|||||||
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 = buildRuntimePrompt('claude', 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) {
|
||||||
@@ -1101,25 +925,13 @@ function launchRuntime(
|
|||||||
cliArgs.push(...args);
|
cliArgs.push(...args);
|
||||||
}
|
}
|
||||||
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
console.log(`[mosaic] Launching ${label}${modeStr}${missionStr}...`);
|
||||||
if (context.recordLaunch !== false)
|
recordLaunch('claude', cliArgs, yolo);
|
||||||
recordLaunch('claude', cliArgs, yolo, context.fleet, launchEnv);
|
execLeaseGatedRuntime('claude', cliArgs, process.env, yolo);
|
||||||
if (process.env['MOSAIC_LAUNCH_ID']) {
|
|
||||||
launchEnv['MOSAIC_LAUNCH_ID'] = process.env['MOSAIC_LAUNCH_ID'];
|
|
||||||
}
|
|
||||||
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);
|
||||||
@@ -1127,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('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('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 = buildRuntimePrompt('pi', 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());
|
||||||
@@ -1168,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1200,7 +993,6 @@ 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'] : [];
|
||||||
@@ -1209,41 +1001,13 @@ function execLeaseGatedRuntime(
|
|||||||
[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. */
|
|
||||||
export function launchFleetRuntime(
|
|
||||||
runtime: RuntimeName,
|
|
||||||
args: string[],
|
|
||||||
declaredEnv: Readonly<Record<string, string>>,
|
|
||||||
fleet: FleetHarnessContext,
|
|
||||||
dangerous = false,
|
|
||||||
): never {
|
|
||||||
return launchRuntime(runtime, args, dangerous, { fleet, declaredEnv });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bounded production-path test seam; all preflight and composition remain real. */
|
|
||||||
export function launchFleetRuntimeForTest(
|
|
||||||
runtime: RuntimeName,
|
|
||||||
args: string[],
|
|
||||||
declaredEnv: Readonly<Record<string, string>>,
|
|
||||||
fleet: FleetHarnessContext,
|
|
||||||
finalExecutor: NonNullable<RuntimeLaunchContext['finalExecutor']>,
|
|
||||||
): never {
|
|
||||||
return launchRuntime(runtime, args, false, {
|
|
||||||
fleet,
|
|
||||||
declaredEnv,
|
|
||||||
runtimeCheck: () => undefined,
|
|
||||||
finalExecutor,
|
|
||||||
recordLaunch: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** exec into the runtime, replacing the current process. */
|
/** exec into the runtime, replacing the current process. */
|
||||||
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
function execRuntime(cmd: string, args: string[], env: NodeJS.ProcessEnv = process.env): void {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
import {
|
|
||||||
existsSync,
|
|
||||||
lstatSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
symlinkSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import { AdoptionError, promoteBundleAlias, promoteStoreEntry, scanAdoptions } from './adoption.js';
|
|
||||||
|
|
||||||
let root: string | undefined;
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (root) await rm(root, { recursive: true, force: true });
|
|
||||||
root = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function userHome(): Promise<string> {
|
|
||||||
root = await mkdtemp(join(tmpdir(), 'mosaic-adopt-'));
|
|
||||||
return join(root, '.mosaic');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A real directory where the primary alias belongs, with something inside worth not losing. */
|
|
||||||
function realAliasDirectory(
|
|
||||||
home: string,
|
|
||||||
harness: string,
|
|
||||||
credential = '.credentials.json',
|
|
||||||
): string {
|
|
||||||
const path = join(home, 'auth', harness, 'primary');
|
|
||||||
mkdirSync(path, { recursive: true });
|
|
||||||
writeFileSync(join(path, credential), '{"token":"kept"}');
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function seat(
|
|
||||||
home: string,
|
|
||||||
name: string,
|
|
||||||
profile: Record<string, unknown> = { schema: 1, harness: 'claude', bundle: 'primary' },
|
|
||||||
): string {
|
|
||||||
const dir = join(home, 'fleet', 'agents', name);
|
|
||||||
mkdirSync(dir, { recursive: true });
|
|
||||||
writeFileSync(join(dir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`);
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A real plugin/skill directory inside a seat, where a link into the store belongs. */
|
|
||||||
function seatDirectory(
|
|
||||||
home: string,
|
|
||||||
agent: string,
|
|
||||||
harness: string,
|
|
||||||
plural: string,
|
|
||||||
name: string,
|
|
||||||
): string {
|
|
||||||
const path = join(home, 'fleet', 'agents', agent, `.${harness}`, plural, name);
|
|
||||||
mkdirSync(path, { recursive: true });
|
|
||||||
writeFileSync(join(path, 'marker.txt'), 'kept');
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('scanAdoptions', () => {
|
|
||||||
it('finds nothing on a host that has no ~/.mosaic at all', async () => {
|
|
||||||
expect(scanAdoptions(await userHome())).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds a real directory on the primary alias path and names the command that resolves it', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const path = realAliasDirectory(home, 'claude');
|
|
||||||
|
|
||||||
const findings = scanAdoptions(home);
|
|
||||||
|
|
||||||
expect(findings).toHaveLength(1);
|
|
||||||
expect(findings[0]?.kind).toBe('bundle-alias');
|
|
||||||
expect(findings[0]?.path).toBe(path);
|
|
||||||
expect(findings[0]?.harness).toBe('claude');
|
|
||||||
expect(findings[0]?.blocked).toBeUndefined();
|
|
||||||
expect(findings[0]?.remedy).toBe('mosaic fleet adopt bundle --harness claude --as <account>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores a primary alias that is already a symlink', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
mkdirSync(join(home, 'auth', 'claude', 'jason_woltje.com'), { recursive: true });
|
|
||||||
symlinkSync('jason_woltje.com', join(home, 'auth', 'claude', 'primary'));
|
|
||||||
|
|
||||||
expect(scanAdoptions(home)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds a real plugin directory inside a seat', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'uc-e6-coder');
|
|
||||||
const path = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
|
|
||||||
|
|
||||||
const findings = scanAdoptions(home);
|
|
||||||
|
|
||||||
expect(findings).toHaveLength(1);
|
|
||||||
expect(findings[0]).toMatchObject({
|
|
||||||
kind: 'store-entry',
|
|
||||||
path,
|
|
||||||
agent: 'uc-e6-coder',
|
|
||||||
store: 'plugin',
|
|
||||||
name: 'reviewer',
|
|
||||||
});
|
|
||||||
expect(findings[0]?.remedy).toBe('mosaic fleet adopt plugin reviewer --seat uc-e6-coder');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds skills the same way it finds plugins', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'uc-e6-rev');
|
|
||||||
seatDirectory(home, 'uc-e6-rev', 'claude', 'skills', 'spec-audit');
|
|
||||||
|
|
||||||
const findings = scanAdoptions(home);
|
|
||||||
|
|
||||||
expect(findings).toHaveLength(1);
|
|
||||||
expect(findings[0]?.store).toBe('skill');
|
|
||||||
expect(findings[0]?.remedy).toBe('mosaic fleet adopt skill spec-audit --seat uc-e6-rev');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scanning the wrong directory name would report nothing on a pi seat while launch keeps
|
|
||||||
// refusing to compose it, which is worse than not having the scan.
|
|
||||||
it('looks in the seat home the launcher uses, not always the claude one', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'terra', { schema: 1, harness: 'pi', bundle: 'primary' });
|
|
||||||
const path = seatDirectory(home, 'terra', 'pi', 'plugins', 'notes');
|
|
||||||
|
|
||||||
expect(scanAdoptions(home).map((finding) => finding.path)).toEqual([path]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not report a link that is already pointing into the store', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'uc-e6-coder');
|
|
||||||
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
|
|
||||||
const installRoot = join(home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins');
|
|
||||||
mkdirSync(installRoot, { recursive: true });
|
|
||||||
symlinkSync(join(home, 'plugins', 'reviewer'), join(installRoot, 'reviewer'));
|
|
||||||
|
|
||||||
expect(scanAdoptions(home)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks the finding blocked when the store already holds that name', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'uc-e6-coder');
|
|
||||||
seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
|
|
||||||
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
|
|
||||||
|
|
||||||
const findings = scanAdoptions(home);
|
|
||||||
|
|
||||||
expect(findings[0]?.blocked).toBe('destination occupied');
|
|
||||||
expect(findings[0]?.remedy).toContain('compare the two');
|
|
||||||
});
|
|
||||||
|
|
||||||
// One malformed profile hiding every finding behind it would make the scan useless exactly
|
|
||||||
// on the hosts that need it most.
|
|
||||||
it('reports an unreadable seat as a gap and keeps scanning the others', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
mkdirSync(join(home, 'fleet', 'agents', 'broken'), { recursive: true });
|
|
||||||
writeFileSync(join(home, 'fleet', 'agents', 'broken', 'profile.json'), 'not json');
|
|
||||||
seat(home, 'working');
|
|
||||||
const path = seatDirectory(home, 'working', 'claude', 'plugins', 'reviewer');
|
|
||||||
|
|
||||||
const findings = scanAdoptions(home);
|
|
||||||
|
|
||||||
expect(findings.map((finding) => finding.kind)).toEqual(['unreadable-seat', 'store-entry']);
|
|
||||||
expect(findings[0]?.blocked).toBe('unreadable profile');
|
|
||||||
expect(findings[1]?.path).toBe(path);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('promoteBundleAlias', () => {
|
|
||||||
it('moves the directory to its account name and points the alias at it', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const from = realAliasDirectory(home, 'claude');
|
|
||||||
|
|
||||||
const result = promoteBundleAlias(home, 'claude', 'jason_woltje.com');
|
|
||||||
|
|
||||||
expect(result.to).toBe(join(home, 'auth', 'claude', 'jason_woltje.com'));
|
|
||||||
expect(result.from).toBe(from);
|
|
||||||
// The credential travelled with the directory; adoption is a move, never a re-creation.
|
|
||||||
expect(readFileSync(join(result.to, '.credentials.json'), 'utf8')).toBe('{"token":"kept"}');
|
|
||||||
const alias = lstatSync(result.alias);
|
|
||||||
expect(alias.isSymbolicLink()).toBe(true);
|
|
||||||
expect(scanAdoptions(home)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses when the alias path is already a symlink', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
mkdirSync(join(home, 'auth', 'pi', 'jason_woltje.com'), { recursive: true });
|
|
||||||
symlinkSync('jason_woltje.com', join(home, 'auth', 'pi', 'primary'));
|
|
||||||
|
|
||||||
expect(() => promoteBundleAlias(home, 'pi', 'other')).toThrow(
|
|
||||||
/already an alias symlink.*mosaic auth default/su,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses when there is nothing on the alias path', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
expect(() => promoteBundleAlias(home, 'claude', 'jason_woltje.com')).toThrow(AdoptionError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to adopt a directory as the alias name itself', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
realAliasDirectory(home, 'claude');
|
|
||||||
|
|
||||||
expect(() => promoteBundleAlias(home, 'claude', 'primary')).toThrow(
|
|
||||||
/that is the alias being freed/u,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a name that would escape the auth root', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
realAliasDirectory(home, 'claude');
|
|
||||||
|
|
||||||
expect(() => promoteBundleAlias(home, 'claude', '../elsewhere')).toThrow(
|
|
||||||
/not a safe bundle name/u,
|
|
||||||
);
|
|
||||||
expect(existsSync(join(home, 'auth', 'claude', 'primary', '.credentials.json'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The failure that would cost data: an occupied destination silently merged into, or worse,
|
|
||||||
// replaced. Both directories must still be exactly where they were.
|
|
||||||
it('refuses an occupied destination and moves nothing', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
realAliasDirectory(home, 'claude');
|
|
||||||
const occupied = join(home, 'auth', 'claude', 'jason_woltje.com');
|
|
||||||
mkdirSync(occupied, { recursive: true });
|
|
||||||
writeFileSync(join(occupied, '.credentials.json'), '{"token":"other"}');
|
|
||||||
|
|
||||||
expect(() => promoteBundleAlias(home, 'claude', 'jason_woltje.com')).toThrow(
|
|
||||||
/already exists and will not be overwritten/u,
|
|
||||||
);
|
|
||||||
expect(readFileSync(join(home, 'auth', 'claude', 'primary', '.credentials.json'), 'utf8')).toBe(
|
|
||||||
'{"token":"kept"}',
|
|
||||||
);
|
|
||||||
expect(readFileSync(join(occupied, '.credentials.json'), 'utf8')).toBe('{"token":"other"}');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('promoteStoreEntry', () => {
|
|
||||||
it('moves the directory into the central store, creating the store root', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'uc-e6-coder', {
|
|
||||||
schema: 1,
|
|
||||||
harness: 'claude',
|
|
||||||
bundle: 'primary',
|
|
||||||
plugins: ['reviewer'],
|
|
||||||
});
|
|
||||||
const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
|
|
||||||
|
|
||||||
const result = promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer');
|
|
||||||
|
|
||||||
expect(result.to).toBe(join(home, 'plugins', 'reviewer'));
|
|
||||||
expect(readFileSync(join(result.to, 'marker.txt'), 'utf8')).toBe('kept');
|
|
||||||
expect(existsSync(from)).toBe(false);
|
|
||||||
expect(result.listedInProfile).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Installing the link here would fail the next launch as an unrecorded symlink, because the
|
|
||||||
// seat's .mosaic-managed-links.json is launch's to write. Adoption stops at the move.
|
|
||||||
it('leaves the seat path empty rather than installing the link itself', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'uc-e6-coder', {
|
|
||||||
schema: 1,
|
|
||||||
harness: 'claude',
|
|
||||||
bundle: 'primary',
|
|
||||||
plugins: ['reviewer'],
|
|
||||||
});
|
|
||||||
const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
|
|
||||||
|
|
||||||
promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer');
|
|
||||||
|
|
||||||
expect(existsSync(from)).toBe(false);
|
|
||||||
expect(() => lstatSync(from)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('says when the seat does not list the entry, because then nothing links it back', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'uc-e6-coder');
|
|
||||||
seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
|
|
||||||
|
|
||||||
expect(promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer').listedInProfile).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses an occupied destination and moves nothing', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'uc-e6-coder');
|
|
||||||
const from = seatDirectory(home, 'uc-e6-coder', 'claude', 'plugins', 'reviewer');
|
|
||||||
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
|
|
||||||
writeFileSync(join(home, 'plugins', 'reviewer', 'marker.txt'), 'store copy');
|
|
||||||
|
|
||||||
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer')).toThrow(
|
|
||||||
/already exists and will not be overwritten/u,
|
|
||||||
);
|
|
||||||
expect(readFileSync(join(from, 'marker.txt'), 'utf8')).toBe('kept');
|
|
||||||
expect(readFileSync(join(home, 'plugins', 'reviewer', 'marker.txt'), 'utf8')).toBe(
|
|
||||||
'store copy',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses an entry that is already a link into the store', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'uc-e6-coder');
|
|
||||||
mkdirSync(join(home, 'plugins', 'reviewer'), { recursive: true });
|
|
||||||
const installRoot = join(home, 'fleet', 'agents', 'uc-e6-coder', '.claude', 'plugins');
|
|
||||||
mkdirSync(installRoot, { recursive: true });
|
|
||||||
symlinkSync(join(home, 'plugins', 'reviewer'), join(installRoot, 'reviewer'));
|
|
||||||
|
|
||||||
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', 'reviewer')).toThrow(
|
|
||||||
/already a link into the store/u,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a name that would escape the store root', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'uc-e6-coder');
|
|
||||||
|
|
||||||
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'plugin', '../escape')).toThrow(
|
|
||||||
/not a safe plugin name/u,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('names the profile it could not read rather than guessing the seat home', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
|
|
||||||
expect(() => promoteStoreEntry(home, 'ghost', 'plugin', 'reviewer')).toThrow(
|
|
||||||
/ghost.*profile\.json.*cannot be located/su,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports a missing directory as nothing to adopt', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
seat(home, 'uc-e6-coder');
|
|
||||||
|
|
||||||
expect(() => promoteStoreEntry(home, 'uc-e6-coder', 'skill', 'absent')).toThrow(
|
|
||||||
/no such skill directory/u,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
/**
|
|
||||||
* Adopting real directories that sit where the fleet expects a managed link.
|
|
||||||
*
|
|
||||||
* A host used before the fleet arrived -- or an operator who ran a login by hand -- ends up
|
|
||||||
* with a real directory on a path launch reserves for a link: `auth/<harness>/primary`, or a
|
|
||||||
* plugin/skill directory inside a seat's home. Launch refuses those on purpose, because the
|
|
||||||
* only way to make a link fit there is to delete whatever is already there.
|
|
||||||
*
|
|
||||||
* This module is the other half of that refusal. It finds those directories and moves them
|
|
||||||
* where they belong. Nothing here deletes anything: a promotion is a rename, and an occupied
|
|
||||||
* destination is a refusal rather than a merge or an overwrite. Cross-device renames are
|
|
||||||
* surfaced instead of being retried as copy-then-delete, because a copy-then-delete is a
|
|
||||||
* delete and this module does not do that.
|
|
||||||
*
|
|
||||||
* Link creation is deliberately NOT done here. Seat store links are recorded in the seat's
|
|
||||||
* `.mosaic-managed-links.json`, and that manifest is owned by launch -- a link installed
|
|
||||||
* behind its back reads as "unrecorded symlink occupies managed path" on the next launch,
|
|
||||||
* which trades one refusal for another. So a promoted plugin lands in the central store and
|
|
||||||
* the next launch links it, provided the seat's profile lists it. Whether a seat gets a
|
|
||||||
* plugin is `mosaic fleet plugin`'s decision, not this one's.
|
|
||||||
*
|
|
||||||
* The auth alias is different: it lives in the auth root, no manifest covers it, and
|
|
||||||
* setDefaultBundle() already owns installing it. So a bundle promotion finishes the job.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, type Stats } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { PRIMARY_ALIAS, assertSafeBundleName, authRoot, setDefaultBundle } from './auth-bundles.js';
|
|
||||||
import type { CredentialHarness } from './credential-sharing.js';
|
|
||||||
|
|
||||||
/** Mirrors the harness list the auth and launch surfaces accept. */
|
|
||||||
const HARNESSES: readonly CredentialHarness[] = ['claude', 'codex', 'opencode', 'pi'];
|
|
||||||
|
|
||||||
/** Store kinds a seat can hold, and the directory name each uses in both trees. */
|
|
||||||
const STORE_DIRECTORY: Record<StoreKind, string> = { plugin: 'plugins', skill: 'skills' };
|
|
||||||
|
|
||||||
/** Same charset as a bundle name; anything with a separator or a dot-dot never reaches a join. */
|
|
||||||
const ENTRY_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
|
|
||||||
|
|
||||||
export type StoreKind = 'plugin' | 'skill';
|
|
||||||
|
|
||||||
export type AdoptionErrorCode =
|
|
||||||
| 'invalid-request'
|
|
||||||
| 'nothing-to-adopt'
|
|
||||||
| 'destination-occupied'
|
|
||||||
| 'cross-device'
|
|
||||||
| 'unsafe-shape';
|
|
||||||
|
|
||||||
export class AdoptionError extends Error {
|
|
||||||
readonly code: AdoptionErrorCode;
|
|
||||||
|
|
||||||
constructor(code: AdoptionErrorCode, message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'AdoptionError';
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdoptionFinding {
|
|
||||||
/** `bundle-alias` and `store-entry` are adoptable; `unreadable-seat` is a scan gap. */
|
|
||||||
readonly kind: 'bundle-alias' | 'store-entry' | 'unreadable-seat';
|
|
||||||
/** The real directory that a launch would refuse to touch. */
|
|
||||||
readonly path: string;
|
|
||||||
/** What this is, in one line. */
|
|
||||||
readonly reason: string;
|
|
||||||
/** The exact command that resolves it, or what to look at when nothing can. */
|
|
||||||
readonly remedy: string;
|
|
||||||
readonly harness?: CredentialHarness;
|
|
||||||
readonly agent?: string;
|
|
||||||
readonly store?: StoreKind;
|
|
||||||
readonly name?: string;
|
|
||||||
/** Set when the promotion cannot run as-is; the remedy then describes the obstacle. */
|
|
||||||
readonly blocked?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BundlePromotion {
|
|
||||||
readonly harness: CredentialHarness;
|
|
||||||
/** Where the adopted directory now lives. */
|
|
||||||
readonly bundle: string;
|
|
||||||
readonly from: string;
|
|
||||||
readonly to: string;
|
|
||||||
/** The alias path now pointing at it. */
|
|
||||||
readonly alias: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorePromotion {
|
|
||||||
readonly agent: string;
|
|
||||||
readonly store: StoreKind;
|
|
||||||
readonly name: string;
|
|
||||||
readonly from: string;
|
|
||||||
readonly to: string;
|
|
||||||
/** True when the seat's profile lists this entry, so the next launch will link it back. */
|
|
||||||
readonly listedInProfile: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lstatIfPresent(path: string): Stats | undefined {
|
|
||||||
try {
|
|
||||||
return lstatSync(path);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRealDirectory(path: string): boolean {
|
|
||||||
const info = lstatIfPresent(path);
|
|
||||||
return info !== undefined && info.isDirectory() && !info.isSymbolicLink();
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSafeEntryName(name: string, store: StoreKind): void {
|
|
||||||
if (!ENTRY_NAME.test(name)) {
|
|
||||||
throw new AdoptionError(
|
|
||||||
'invalid-request',
|
|
||||||
`"${name}" is not a safe ${store} name; use letters, digits, and . _ @ -`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function agentsRoot(dataHome: string): string {
|
|
||||||
return join(dataHome, 'fleet', 'agents');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A seat's harness home, by the same rule launch uses (`harnessHome()` in commands/launch.ts).
|
|
||||||
* Scanning by any other rule finds directories launch never looks at and misses the ones it
|
|
||||||
* refuses on.
|
|
||||||
*/
|
|
||||||
function seatHome(dataHome: string, agent: string, harness: CredentialHarness): string {
|
|
||||||
return join(agentsRoot(dataHome), agent, `.${harness}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SeatProfile {
|
|
||||||
readonly harness: CredentialHarness;
|
|
||||||
readonly plugins: readonly string[];
|
|
||||||
readonly skills: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read only what adoption needs out of a seat profile, leniently.
|
|
||||||
*
|
|
||||||
* A scan that dies on one malformed profile hides every finding behind it, so an unreadable
|
|
||||||
* profile is reported as a scan gap and the walk continues. Strictness belongs at launch,
|
|
||||||
* which validates the whole profile and refuses to run the seat.
|
|
||||||
*/
|
|
||||||
function readSeatProfile(dataHome: string, agent: string): SeatProfile | undefined {
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(readFileSync(join(agentsRoot(dataHome), agent, 'profile.json'), 'utf8'));
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
|
|
||||||
const raw = parsed as Record<string, unknown>;
|
|
||||||
const harness = raw['harness'];
|
|
||||||
if (typeof harness !== 'string' || !HARNESSES.includes(harness as CredentialHarness)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const names = (value: unknown): string[] =>
|
|
||||||
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
||||||
return {
|
|
||||||
harness: harness as CredentialHarness,
|
|
||||||
plugins: names(raw['plugins']),
|
|
||||||
skills: names(raw['skills']),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function listDirectory(path: string): string[] {
|
|
||||||
try {
|
|
||||||
return readdirSync(path, { withFileTypes: true })
|
|
||||||
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink())
|
|
||||||
.map((entry) => entry.name)
|
|
||||||
.sort();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function listAgents(dataHome: string): string[] {
|
|
||||||
try {
|
|
||||||
return readdirSync(agentsRoot(dataHome), { withFileTypes: true })
|
|
||||||
.filter((entry) => entry.isDirectory())
|
|
||||||
.map((entry) => entry.name)
|
|
||||||
.sort();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Everything under `~/.mosaic` that occupies a path the fleet manages with a link.
|
|
||||||
*
|
|
||||||
* Read-only. Every finding carries the command that resolves it, because the value of the
|
|
||||||
* scan is that an operator does not have to work out what a composition refusal meant.
|
|
||||||
*/
|
|
||||||
export function scanAdoptions(dataHome: string): AdoptionFinding[] {
|
|
||||||
const findings: AdoptionFinding[] = [];
|
|
||||||
|
|
||||||
for (const harness of HARNESSES) {
|
|
||||||
const alias = join(authRoot(dataHome, harness), PRIMARY_ALIAS);
|
|
||||||
if (!isRealDirectory(alias)) continue;
|
|
||||||
findings.push({
|
|
||||||
kind: 'bundle-alias',
|
|
||||||
path: alias,
|
|
||||||
harness,
|
|
||||||
reason: `a real directory occupies the ${PRIMARY_ALIAS} alias path; ${harness} seats pointed at "${PRIMARY_ALIAS}" cannot launch`,
|
|
||||||
remedy: `mosaic fleet adopt bundle --harness ${harness} --as <account>`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const agent of listAgents(dataHome)) {
|
|
||||||
const profile = readSeatProfile(dataHome, agent);
|
|
||||||
if (profile === undefined) {
|
|
||||||
findings.push({
|
|
||||||
kind: 'unreadable-seat',
|
|
||||||
path: join(agentsRoot(dataHome), agent, 'profile.json'),
|
|
||||||
agent,
|
|
||||||
reason:
|
|
||||||
'profile could not be read, or names no known harness, so this seat was not scanned',
|
|
||||||
remedy: `mosaic fleet agent get ${agent}`,
|
|
||||||
blocked: 'unreadable profile',
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const store of ['plugin', 'skill'] as const) {
|
|
||||||
const plural = STORE_DIRECTORY[store];
|
|
||||||
const installRoot = join(seatHome(dataHome, agent, profile.harness), plural);
|
|
||||||
for (const name of listDirectory(installRoot)) {
|
|
||||||
const destination = join(dataHome, plural, name);
|
|
||||||
const occupied = lstatIfPresent(destination) !== undefined;
|
|
||||||
findings.push({
|
|
||||||
kind: 'store-entry',
|
|
||||||
path: join(installRoot, name),
|
|
||||||
agent,
|
|
||||||
store,
|
|
||||||
name,
|
|
||||||
reason: `a real ${store} directory sits where the seat expects a link into the central store`,
|
|
||||||
remedy: occupied
|
|
||||||
? `${destination} already exists; compare the two and remove or rename one by hand`
|
|
||||||
: `mosaic fleet adopt ${store} ${name} --seat ${agent}`,
|
|
||||||
...(occupied ? { blocked: 'destination occupied' } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return findings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move a directory, refusing every case where the move would cost data.
|
|
||||||
*
|
|
||||||
* EXDEV is surfaced rather than handled: the fallback for a cross-device rename is copy then
|
|
||||||
* delete, and this module does not delete.
|
|
||||||
*/
|
|
||||||
function movePreservingBoth(from: string, to: string, label: string): void {
|
|
||||||
if (lstatIfPresent(to) !== undefined) {
|
|
||||||
throw new AdoptionError(
|
|
||||||
'destination-occupied',
|
|
||||||
`${label} destination already exists and will not be overwritten: ${to}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
renameSync(from, to);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'EXDEV') {
|
|
||||||
throw new AdoptionError(
|
|
||||||
'cross-device',
|
|
||||||
`${from} and ${to} are on different filesystems, so this cannot be a rename. Copy it across yourself and remove the original once you have checked the copy: ${to}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adopt a real directory sitting on the `primary` alias path as a named bundle.
|
|
||||||
*
|
|
||||||
* The directory is moved to its account name first and the alias installed second. That order
|
|
||||||
* is the one that survives a failure: if the alias cannot be created, the credentials are
|
|
||||||
* intact under their own name and the error says where they are. The reverse order would have
|
|
||||||
* a window where the alias points at nothing.
|
|
||||||
*/
|
|
||||||
export function promoteBundleAlias(
|
|
||||||
dataHome: string,
|
|
||||||
harness: CredentialHarness,
|
|
||||||
as: string,
|
|
||||||
): BundlePromotion {
|
|
||||||
assertSafeBundleName(as);
|
|
||||||
if (as === PRIMARY_ALIAS) {
|
|
||||||
throw new AdoptionError(
|
|
||||||
'invalid-request',
|
|
||||||
`--as must be the account this directory holds, not "${PRIMARY_ALIAS}" — that is the alias being freed`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const root = authRoot(dataHome, harness);
|
|
||||||
const alias = join(root, PRIMARY_ALIAS);
|
|
||||||
const info = lstatIfPresent(alias);
|
|
||||||
if (info === undefined) {
|
|
||||||
throw new AdoptionError('nothing-to-adopt', `nothing at ${alias}; there is nothing to adopt`);
|
|
||||||
}
|
|
||||||
if (info.isSymbolicLink()) {
|
|
||||||
throw new AdoptionError(
|
|
||||||
'nothing-to-adopt',
|
|
||||||
`${alias} is already an alias symlink. Retarget it with: mosaic auth default --harness ${harness} <bundle>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!info.isDirectory()) {
|
|
||||||
throw new AdoptionError(
|
|
||||||
'unsafe-shape',
|
|
||||||
`${alias} is neither a directory nor a symlink; adoption only moves directories`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const destination = join(root, as);
|
|
||||||
movePreservingBoth(alias, destination, 'bundle');
|
|
||||||
return {
|
|
||||||
harness,
|
|
||||||
bundle: as,
|
|
||||||
from: alias,
|
|
||||||
to: destination,
|
|
||||||
alias: setDefaultBundle(dataHome, harness, as),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adopt a real plugin/skill directory out of a seat and into the central store.
|
|
||||||
*
|
|
||||||
* No link is installed. The seat's link manifest belongs to launch, and a link this command
|
|
||||||
* created behind it would fail the next composition as an unrecorded symlink. The next launch
|
|
||||||
* installs and records the link itself when the seat's profile lists the entry -- and when it
|
|
||||||
* does not, the entry is now vetted store content that any seat can be given deliberately,
|
|
||||||
* which is the outcome that was wanted anyway.
|
|
||||||
*/
|
|
||||||
export function promoteStoreEntry(
|
|
||||||
dataHome: string,
|
|
||||||
agent: string,
|
|
||||||
store: StoreKind,
|
|
||||||
name: string,
|
|
||||||
): StorePromotion {
|
|
||||||
assertSafeEntryName(name, store);
|
|
||||||
const profile = readSeatProfile(dataHome, agent);
|
|
||||||
if (profile === undefined) {
|
|
||||||
throw new AdoptionError(
|
|
||||||
'invalid-request',
|
|
||||||
`cannot read a harness out of ${join(agentsRoot(dataHome), agent, 'profile.json')}, so the seat's home cannot be located`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const plural = STORE_DIRECTORY[store];
|
|
||||||
const source = join(seatHome(dataHome, agent, profile.harness), plural, name);
|
|
||||||
const info = lstatIfPresent(source);
|
|
||||||
if (info === undefined) {
|
|
||||||
throw new AdoptionError('nothing-to-adopt', `no such ${store} directory: ${source}`);
|
|
||||||
}
|
|
||||||
if (info.isSymbolicLink()) {
|
|
||||||
throw new AdoptionError(
|
|
||||||
'nothing-to-adopt',
|
|
||||||
`${source} is already a link into the store; there is nothing to adopt`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!info.isDirectory()) {
|
|
||||||
throw new AdoptionError(
|
|
||||||
'unsafe-shape',
|
|
||||||
`${source} is not a directory; adoption only moves directories`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(join(dataHome, plural), { recursive: true });
|
|
||||||
const destination = join(dataHome, plural, name);
|
|
||||||
movePreservingBoth(source, destination, store);
|
|
||||||
return {
|
|
||||||
agent,
|
|
||||||
store,
|
|
||||||
name,
|
|
||||||
from: source,
|
|
||||||
to: destination,
|
|
||||||
listedInProfile: (store === 'plugin' ? profile.plugins : profile.skills).includes(name),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { chmodSync, lstatSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
||||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
AuthBundleError,
|
|
||||||
bundleNameForEmail,
|
|
||||||
completeEnrollment,
|
|
||||||
listBundles,
|
|
||||||
prepareEnrollment,
|
|
||||||
readBundleIdentity,
|
|
||||||
setDefaultBundle,
|
|
||||||
} from './auth-bundles.js';
|
|
||||||
|
|
||||||
let root: string | undefined;
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (root) await rm(root, { recursive: true, force: true });
|
|
||||||
root = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function userHome(): Promise<string> {
|
|
||||||
root = await mkdtemp(join(tmpdir(), 'mosaic-auth-'));
|
|
||||||
return join(root, '.mosaic');
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('prepareEnrollment', () => {
|
|
||||||
it('creates the bundle directory owner-only and names the environment the login needs', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
|
||||||
|
|
||||||
expect(plan.created).toBe(true);
|
|
||||||
expect(plan.hadCredential).toBe(false);
|
|
||||||
expect(plan.bundleDir).toBe(join(home, 'auth', 'claude', 'jason_woltje.com'));
|
|
||||||
expect(plan.credentialPath).toBe(join(plan.bundleDir, '.credentials.json'));
|
|
||||||
// Claude reaches its bundle by CLAUDE_SECURESTORAGE_CONFIG_DIR because rename() replaces
|
|
||||||
// a symlink rather than following it; the login has to write into the bundle directly.
|
|
||||||
expect(plan.env).toEqual({
|
|
||||||
CLAUDE_CONFIG_DIR: plan.bundleDir,
|
|
||||||
CLAUDE_SECURESTORAGE_CONFIG_DIR: plan.bundleDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const path of [home, join(home, 'auth'), join(home, 'auth', 'claude'), plan.bundleDir]) {
|
|
||||||
expect(lstatSync(path).mode & 0o077).toBe(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives a harness without a credential-directory variable only its home variable', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const plan = prepareEnrollment(home, 'pi', 'jason_woltje.com');
|
|
||||||
|
|
||||||
expect(plan.credentialPath).toBe(join(plan.bundleDir, 'auth.json'));
|
|
||||||
expect(plan.env).toEqual({ PI_CODING_AGENT_DIR: plan.bundleDir });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to enrol into the primary alias and says what to do instead', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
// `primary` is a movable pointer, not storage. Enrolling into it would turn the alias
|
|
||||||
// into a real directory and there would no longer be a default to move.
|
|
||||||
expect(() => prepareEnrollment(home, 'claude', 'primary')).toThrow(
|
|
||||||
/movable alias, not a bundle/u,
|
|
||||||
);
|
|
||||||
expect(() => prepareEnrollment(home, 'claude', 'primary')).toThrow(AuthBundleError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a bundle name that could escape the auth root', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
expect(() => prepareEnrollment(home, 'claude', '../elsewhere')).toThrow(/not a safe bundle/u);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tightens an existing world-readable bundle directory rather than trusting it', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const bundleDir = join(home, 'auth', 'claude', 'loose');
|
|
||||||
mkdirSync(bundleDir, { recursive: true });
|
|
||||||
chmodSync(bundleDir, 0o755);
|
|
||||||
|
|
||||||
const plan = prepareEnrollment(home, 'claude', 'loose');
|
|
||||||
|
|
||||||
expect(plan.created).toBe(false);
|
|
||||||
expect(lstatSync(plan.bundleDir).mode & 0o077).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports an existing credential so a re-login is not mistaken for a first enrolment', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const first = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
|
||||||
writeFileSync(first.credentialPath, '{}', { mode: 0o600 });
|
|
||||||
|
|
||||||
expect(prepareEnrollment(home, 'claude', 'jason_woltje.com').hadCredential).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('completeEnrollment', () => {
|
|
||||||
it('fails when the login exited without writing a credential', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
|
||||||
|
|
||||||
// The directory exists and looks fine; only the credential proves a login happened. Without
|
|
||||||
// this check the failure surfaces much later, at composition, blaming the missing file
|
|
||||||
// rather than the login that never completed.
|
|
||||||
expect(() => completeEnrollment(plan)).toThrow(/login left no credential/u);
|
|
||||||
try {
|
|
||||||
completeEnrollment(plan);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
expect((error as AuthBundleError).code).toBe('credential-missing');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tightens a credential the harness wrote with group or other permissions', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
|
||||||
writeFileSync(plan.credentialPath, '{}');
|
|
||||||
chmodSync(plan.credentialPath, 0o644);
|
|
||||||
|
|
||||||
const result = completeEnrollment(plan);
|
|
||||||
|
|
||||||
expect(result.tightened).toBe(true);
|
|
||||||
expect(lstatSync(plan.credentialPath).mode & 0o077).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('records the logged-in account so the bundle can say who it holds', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
|
||||||
writeFileSync(plan.credentialPath, '{}', { mode: 0o600 });
|
|
||||||
writeFileSync(
|
|
||||||
join(plan.bundleDir, '.claude.json'),
|
|
||||||
JSON.stringify({ oauthAccount: { emailAddress: '[email protected]' } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = completeEnrollment(plan);
|
|
||||||
|
|
||||||
expect(result.email).toBe('[email protected]');
|
|
||||||
expect(result.identityMismatch).toBeUndefined();
|
|
||||||
const recorded = JSON.parse(
|
|
||||||
await readFile(join(plan.bundleDir, 'account.json'), 'utf8'),
|
|
||||||
) as Record<string, unknown>;
|
|
||||||
expect(recorded['emailAddress']).toBe('[email protected]');
|
|
||||||
expect(lstatSync(join(plan.bundleDir, 'account.json')).mode & 0o077).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flags a bundle whose name does not match the account that logged into it', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
// This is the failure the whole two-principal model rests on. If an operator enrolling a
|
|
||||||
// reviewer bundle logs in as the author's account by habit, both seats end up holding one
|
|
||||||
// principal, the review is self-review, and nothing else in the system notices.
|
|
||||||
const plan = prepareEnrollment(home, 'claude', 'reviewer_example.com');
|
|
||||||
writeFileSync(plan.credentialPath, '{}', { mode: 0o600 });
|
|
||||||
writeFileSync(
|
|
||||||
join(plan.bundleDir, '.claude.json'),
|
|
||||||
JSON.stringify({ oauthAccount: { emailAddress: '[email protected]' } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = completeEnrollment(plan);
|
|
||||||
|
|
||||||
expect(result.email).toBe('[email protected]');
|
|
||||||
expect(result.identityMismatch).toBe('author_example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enrols a harness whose files carry no identity, without inventing one', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const plan = prepareEnrollment(home, 'pi', 'someone_example.com');
|
|
||||||
writeFileSync(plan.credentialPath, JSON.stringify({ token: 'x' }), { mode: 0o600 });
|
|
||||||
|
|
||||||
const result = completeEnrollment(plan);
|
|
||||||
|
|
||||||
expect(result.email).toBeUndefined();
|
|
||||||
expect(result.identityMismatch).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('bundleNameForEmail', () => {
|
|
||||||
it('maps an account to its bundle name', () => {
|
|
||||||
expect(bundleNameForEmail('[email protected]')).toBe('jason.woltje_uscllc.com');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('readBundleIdentity', () => {
|
|
||||||
it('prefers the recorded account over whatever the harness left lying around', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
|
||||||
writeFileSync(join(plan.bundleDir, 'account.json'), JSON.stringify({ emailAddress: '[email protected]' }));
|
|
||||||
writeFileSync(
|
|
||||||
join(plan.bundleDir, '.claude.json'),
|
|
||||||
JSON.stringify({ oauthAccount: { emailAddress: '[email protected]' } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(readBundleIdentity(plan.bundleDir, 'claude')).toBe('[email protected]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns nothing rather than guessing when the files are unreadable', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const plan = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
|
||||||
writeFileSync(join(plan.bundleDir, '.claude.json'), 'not json');
|
|
||||||
|
|
||||||
expect(readBundleIdentity(plan.bundleDir, 'claude')).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('listBundles', () => {
|
|
||||||
it('is empty on a host that has never enrolled anything', async () => {
|
|
||||||
expect(listBundles(await userHome(), 'claude')).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports enrolment state, the alias, and which account each bundle holds', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
const enrolled = prepareEnrollment(home, 'claude', 'jason_woltje.com');
|
|
||||||
writeFileSync(enrolled.credentialPath, '{}', { mode: 0o600 });
|
|
||||||
writeFileSync(
|
|
||||||
join(enrolled.bundleDir, 'account.json'),
|
|
||||||
JSON.stringify({ emailAddress: '[email protected]' }),
|
|
||||||
);
|
|
||||||
prepareEnrollment(home, 'claude', 'empty_example.com');
|
|
||||||
setDefaultBundle(home, 'claude', 'jason_woltje.com');
|
|
||||||
|
|
||||||
const bundles = listBundles(home, 'claude');
|
|
||||||
|
|
||||||
expect(bundles.map((b) => b.name)).toEqual([
|
|
||||||
'empty_example.com',
|
|
||||||
'jason_woltje.com',
|
|
||||||
'primary',
|
|
||||||
]);
|
|
||||||
expect(bundles.find((b) => b.name === 'jason_woltje.com')).toMatchObject({
|
|
||||||
alias: false,
|
|
||||||
enrolled: true,
|
|
||||||
email: '[email protected]',
|
|
||||||
});
|
|
||||||
expect(bundles.find((b) => b.name === 'empty_example.com')).toMatchObject({
|
|
||||||
alias: false,
|
|
||||||
enrolled: false,
|
|
||||||
});
|
|
||||||
expect(bundles.find((b) => b.name === 'primary')).toMatchObject({
|
|
||||||
alias: true,
|
|
||||||
target: 'jason_woltje.com',
|
|
||||||
enrolled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a dangling alias instead of failing the whole listing', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
mkdirSync(join(home, 'auth', 'claude'), { recursive: true });
|
|
||||||
symlinkSync('gone', join(home, 'auth', 'claude', 'primary'));
|
|
||||||
|
|
||||||
expect(listBundles(home, 'claude')).toEqual([
|
|
||||||
{
|
|
||||||
name: 'primary',
|
|
||||||
path: join(home, 'auth', 'claude', 'primary'),
|
|
||||||
resolved: join(home, 'auth', 'claude', 'primary'),
|
|
||||||
alias: true,
|
|
||||||
enrolled: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setDefaultBundle', () => {
|
|
||||||
it('retargets an existing alias without writing through into the old bundle', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
for (const name of ['one_example.com', 'two_example.com']) {
|
|
||||||
const plan = prepareEnrollment(home, 'claude', name);
|
|
||||||
writeFileSync(plan.credentialPath, '{}', { mode: 0o600 });
|
|
||||||
}
|
|
||||||
setDefaultBundle(home, 'claude', 'one_example.com');
|
|
||||||
setDefaultBundle(home, 'claude', 'two_example.com');
|
|
||||||
|
|
||||||
expect(listBundles(home, 'claude').find((b) => b.name === 'primary')?.target).toBe(
|
|
||||||
'two_example.com',
|
|
||||||
);
|
|
||||||
// The bundle it used to point at is untouched, not emptied by the retarget.
|
|
||||||
expect(
|
|
||||||
lstatSync(join(home, 'auth', 'claude', 'one_example.com', '.credentials.json')).isFile(),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses to point the alias at a bundle that does not exist', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
mkdirSync(join(home, 'auth', 'claude'), { recursive: true });
|
|
||||||
|
|
||||||
expect(() => setDefaultBundle(home, 'claude', 'missing_example.com')).toThrow(
|
|
||||||
/no such bundle/u,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('will not delete a real directory that occupies the alias path', async () => {
|
|
||||||
const home = await userHome();
|
|
||||||
prepareEnrollment(home, 'claude', 'real_example.com');
|
|
||||||
mkdirSync(join(home, 'auth', 'claude', 'primary'), { recursive: true });
|
|
||||||
|
|
||||||
// A real `primary` directory means someone enrolled into the alias by hand and their
|
|
||||||
// credentials are inside it. Deleting it to install a symlink would destroy an account.
|
|
||||||
expect(() => setDefaultBundle(home, 'claude', 'real_example.com')).toThrow(
|
|
||||||
/will not be deleted/u,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
/**
|
|
||||||
* Credential bundles under `~/.mosaic/auth/<harness>/<bundle>/`.
|
|
||||||
*
|
|
||||||
* A bundle is one account's credentials for one harness. Seats point at a bundle by name in
|
|
||||||
* their `profile.json`, so two seats can hold genuinely different principals on one host --
|
|
||||||
* which is the whole reason the fleet can run an author seat and a reviewer seat without the
|
|
||||||
* review being self-review wearing two hats.
|
|
||||||
*
|
|
||||||
* Enrolling does not reimplement any harness's login. It creates the bundle directory, points
|
|
||||||
* the harness at it by environment, and runs the harness's own login. What this module owns is
|
|
||||||
* everything around that: that the directory is a real directory nobody can read but its owner,
|
|
||||||
* that the credential actually landed, and that the account you logged in as is the account the
|
|
||||||
* bundle claims to hold.
|
|
||||||
*
|
|
||||||
* Composition-side reader: commands/fleet-launch-command.ts resolveCredential().
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
chmodSync,
|
|
||||||
lstatSync,
|
|
||||||
mkdirSync,
|
|
||||||
readFileSync,
|
|
||||||
readdirSync,
|
|
||||||
realpathSync,
|
|
||||||
rmSync,
|
|
||||||
symlinkSync,
|
|
||||||
writeFileSync,
|
|
||||||
type Stats,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
||||||
import {
|
|
||||||
CREDENTIAL_DIR_ENV,
|
|
||||||
CREDENTIAL_FILE_NAMES,
|
|
||||||
type CredentialHarness,
|
|
||||||
} from './credential-sharing.js';
|
|
||||||
|
|
||||||
/** Mirrors BUNDLE_NAME in commands/fleet-launch-command.ts; drift here is a launch failure. */
|
|
||||||
const BUNDLE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.@-]*$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The movable alias. `"bundle": "primary"` in a profile follows whatever this points at; a
|
|
||||||
* named bundle stays pinned. It is the only symlink launch tolerates in an auth root.
|
|
||||||
*/
|
|
||||||
export const PRIMARY_ALIAS = 'primary';
|
|
||||||
|
|
||||||
/** Where each harness expects its own home, so login writes into the bundle we just made. */
|
|
||||||
const HOME_ENV_NAME: Record<CredentialHarness, string> = {
|
|
||||||
claude: 'CLAUDE_CONFIG_DIR',
|
|
||||||
pi: 'PI_CODING_AGENT_DIR',
|
|
||||||
codex: 'CODEX_HOME',
|
|
||||||
opencode: 'XDG_CONFIG_HOME',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Files a harness writes that carry the logged-in account's identity, and the paths within
|
|
||||||
* them to try. Best effort by design: a harness we cannot read an identity from still enrolls,
|
|
||||||
* it just cannot be checked against its bundle name.
|
|
||||||
*/
|
|
||||||
const IDENTITY_SOURCES: Record<CredentialHarness, ReadonlyArray<readonly [string, string[]]>> = {
|
|
||||||
claude: [
|
|
||||||
['.claude.json', ['oauthAccount.emailAddress', 'oauthAccount.email']],
|
|
||||||
['.credentials.json', ['claudeAiOauth.emailAddress']],
|
|
||||||
],
|
|
||||||
pi: [['auth.json', ['account.email', 'email', 'user.email']]],
|
|
||||||
codex: [['auth.json', ['tokens.id_token.email', 'account.email', 'email']]],
|
|
||||||
opencode: [['auth.json', ['account.email', 'email']]],
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AuthBundleErrorCode =
|
|
||||||
| 'invalid-request'
|
|
||||||
| 'bundle-not-found'
|
|
||||||
| 'bundle-exists'
|
|
||||||
| 'credential-missing'
|
|
||||||
| 'unsafe-shape';
|
|
||||||
|
|
||||||
export class AuthBundleError extends Error {
|
|
||||||
readonly code: AuthBundleErrorCode;
|
|
||||||
|
|
||||||
constructor(code: AuthBundleErrorCode, message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'AuthBundleError';
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BundleInfo {
|
|
||||||
readonly name: string;
|
|
||||||
/** Absolute path of the entry as named, before alias resolution. */
|
|
||||||
readonly path: string;
|
|
||||||
/** Where it actually lives. Differs from `path` only for the primary alias. */
|
|
||||||
readonly resolved: string;
|
|
||||||
/** True when this entry is the movable primary alias rather than a real bundle. */
|
|
||||||
readonly alias: boolean;
|
|
||||||
/** Alias target's bundle name, when this is the alias. */
|
|
||||||
readonly target?: string;
|
|
||||||
/** True when the harness's credential file is present in the resolved bundle. */
|
|
||||||
readonly enrolled: boolean;
|
|
||||||
/** Account identity recorded at enrollment, when one could be determined. */
|
|
||||||
readonly email?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnrollmentPlan {
|
|
||||||
readonly harness: CredentialHarness;
|
|
||||||
readonly bundle: string;
|
|
||||||
readonly bundleDir: string;
|
|
||||||
/** Absolute path the harness must end up writing its credential to. */
|
|
||||||
readonly credentialPath: string;
|
|
||||||
/** True when the directory did not exist before this call. */
|
|
||||||
readonly created: boolean;
|
|
||||||
/** True when a credential was already present -- a re-login, not a first enrollment. */
|
|
||||||
readonly hadCredential: boolean;
|
|
||||||
/**
|
|
||||||
* Environment the harness login must run under. Every value is an absolute path; Claude
|
|
||||||
* reads an empty credential-dir value as ~/.claude, the operator's own account, so an
|
|
||||||
* empty value is never produced here.
|
|
||||||
*/
|
|
||||||
readonly env: Readonly<Record<string, string>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnrollmentResult {
|
|
||||||
readonly harness: CredentialHarness;
|
|
||||||
readonly bundle: string;
|
|
||||||
readonly bundleDir: string;
|
|
||||||
readonly credentialPath: string;
|
|
||||||
/** Identity read back out of what the harness wrote, when it could be determined. */
|
|
||||||
readonly email?: string;
|
|
||||||
/**
|
|
||||||
* Set when an identity was found and it does not match the bundle name. Logging into the
|
|
||||||
* wrong account is silent otherwise, and it is the failure that quietly collapses two
|
|
||||||
* principals back into one.
|
|
||||||
*/
|
|
||||||
readonly identityMismatch?: string;
|
|
||||||
/** True when the credential file's permissions had to be tightened to owner-only. */
|
|
||||||
readonly tightened: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lstatIfPresent(path: string): Stats | undefined {
|
|
||||||
try {
|
|
||||||
return lstatSync(path);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertContained(root: string, candidate: string, label: string): void {
|
|
||||||
const rel = relative(resolve(root), resolve(candidate));
|
|
||||||
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
||||||
throw new AuthBundleError('unsafe-shape', `${label} resolves outside ${root}: ${candidate}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reject a name before it is ever joined onto a path. */
|
|
||||||
export function assertSafeBundleName(bundle: string): void {
|
|
||||||
if (!BUNDLE_NAME.test(bundle)) {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'invalid-request',
|
|
||||||
`"${bundle}" is not a safe bundle name; use letters, digits, and . _ @ -`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `~/.mosaic/auth/<harness>`. */
|
|
||||||
export function authRoot(userHome: string, harness: CredentialHarness): string {
|
|
||||||
return join(userHome, 'auth', harness);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the auth root chain with owner-only permissions, refusing anything that is not a
|
|
||||||
* real directory. An explicit mode on mkdir is not enough on its own -- it is masked by the
|
|
||||||
* ambient umask -- so each level is chmod'ed after creation.
|
|
||||||
*/
|
|
||||||
function ensurePrivateDirectory(path: string, label: string): boolean {
|
|
||||||
const info = lstatIfPresent(path);
|
|
||||||
if (info) {
|
|
||||||
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'unsafe-shape',
|
|
||||||
`${label} must be a real, non-symlink directory: ${path}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ((info.mode & 0o077) !== 0) chmodSync(path, 0o700);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
||||||
chmodSync(path, 0o700);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readJson(path: string): Record<string, unknown> | undefined {
|
|
||||||
const info = lstatIfPresent(path);
|
|
||||||
if (!info?.isFile() || info.isSymbolicLink()) return undefined;
|
|
||||||
try {
|
|
||||||
const value: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
||||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dig(source: Record<string, unknown>, dotted: string): string | undefined {
|
|
||||||
let cursor: unknown = source;
|
|
||||||
for (const key of dotted.split('.')) {
|
|
||||||
if (typeof cursor !== 'object' || cursor === null || Array.isArray(cursor)) return undefined;
|
|
||||||
cursor = (cursor as Record<string, unknown>)[key];
|
|
||||||
}
|
|
||||||
return typeof cursor === 'string' && cursor.trim() !== '' ? cursor.trim() : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best-effort account identity from whatever the harness wrote into the bundle. */
|
|
||||||
export function readBundleIdentity(
|
|
||||||
bundleDir: string,
|
|
||||||
harness: CredentialHarness,
|
|
||||||
): string | undefined {
|
|
||||||
const recorded = readJson(join(bundleDir, 'account.json'));
|
|
||||||
if (recorded) {
|
|
||||||
for (const path of ['emailAddress', 'email', 'oauthAccount.emailAddress']) {
|
|
||||||
const found = dig(recorded, path);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [file, paths] of IDENTITY_SOURCES[harness]) {
|
|
||||||
const source = readJson(join(bundleDir, file));
|
|
||||||
if (!source) continue;
|
|
||||||
for (const path of paths) {
|
|
||||||
const found = dig(source, path);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The bundle name an email implies. Bundles are named by account identity so that a roster
|
|
||||||
* row's `"bundle"` says who the seat is, not merely which slot it uses.
|
|
||||||
*/
|
|
||||||
export function bundleNameForEmail(email: string): string {
|
|
||||||
return email.trim().toLowerCase().replace(/@/gu, '_');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the bundle directory and describe the environment its login must run under.
|
|
||||||
*
|
|
||||||
* This deliberately stops short of running anything. The caller runs the harness's own login
|
|
||||||
* under `plan.env`, then calls completeEnrollment() to check what landed.
|
|
||||||
*/
|
|
||||||
export function prepareEnrollment(
|
|
||||||
userHome: string,
|
|
||||||
harness: CredentialHarness,
|
|
||||||
bundle: string,
|
|
||||||
): EnrollmentPlan {
|
|
||||||
assertSafeBundleName(bundle);
|
|
||||||
if (bundle === PRIMARY_ALIAS) {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'invalid-request',
|
|
||||||
`"${PRIMARY_ALIAS}" is a movable alias, not a bundle. Enroll a bundle named for the account (for example: mosaic auth enroll --harness ${harness} --bundle jason_woltje.com), then point the alias at it with: mosaic auth default --harness ${harness} <bundle>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ensurePrivateDirectory(userHome, 'user Mosaic root');
|
|
||||||
ensurePrivateDirectory(join(userHome, 'auth'), 'auth directory');
|
|
||||||
const root = authRoot(userHome, harness);
|
|
||||||
ensurePrivateDirectory(root, `${harness} auth root`);
|
|
||||||
|
|
||||||
const bundleDir = join(root, bundle);
|
|
||||||
assertContained(realpathSync(root), resolve(bundleDir), 'credential bundle');
|
|
||||||
const created = ensurePrivateDirectory(bundleDir, 'credential bundle');
|
|
||||||
|
|
||||||
const credentialPath = join(bundleDir, CREDENTIAL_FILE_NAMES[harness]);
|
|
||||||
const credentialDirEnvName = CREDENTIAL_DIR_ENV[harness];
|
|
||||||
return {
|
|
||||||
harness,
|
|
||||||
bundle,
|
|
||||||
bundleDir,
|
|
||||||
credentialPath,
|
|
||||||
created,
|
|
||||||
hadCredential: lstatIfPresent(credentialPath)?.isFile() === true,
|
|
||||||
env: {
|
|
||||||
[HOME_ENV_NAME[harness]]: bundleDir,
|
|
||||||
...(credentialDirEnvName === undefined ? {} : { [credentialDirEnvName]: bundleDir }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check what the harness login actually left behind, tighten it, and record the identity.
|
|
||||||
*
|
|
||||||
* A login that exits zero having written nothing is the failure worth catching here: the seat
|
|
||||||
* would then fail much later, at composition, with a message about a missing credential and no
|
|
||||||
* hint that the login was the thing that did not work.
|
|
||||||
*/
|
|
||||||
export function completeEnrollment(plan: EnrollmentPlan): EnrollmentResult {
|
|
||||||
const info = lstatIfPresent(plan.credentialPath);
|
|
||||||
if (!info?.isFile() || info.isSymbolicLink()) {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'credential-missing',
|
|
||||||
`login left no credential at ${plan.credentialPath}. The bundle directory exists but is not enrolled; nothing was assigned.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let tightened = false;
|
|
||||||
if ((info.mode & 0o077) !== 0) {
|
|
||||||
chmodSync(plan.credentialPath, 0o600);
|
|
||||||
tightened = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = readBundleIdentity(plan.bundleDir, plan.harness);
|
|
||||||
if (email !== undefined) {
|
|
||||||
writeFileSync(
|
|
||||||
join(plan.bundleDir, 'account.json'),
|
|
||||||
`${JSON.stringify({ emailAddress: email, harness: plan.harness }, null, 2)}\n`,
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
chmodSync(join(plan.bundleDir, 'account.json'), 0o600);
|
|
||||||
}
|
|
||||||
|
|
||||||
const expected = email === undefined ? undefined : bundleNameForEmail(email);
|
|
||||||
return {
|
|
||||||
harness: plan.harness,
|
|
||||||
bundle: plan.bundle,
|
|
||||||
bundleDir: plan.bundleDir,
|
|
||||||
credentialPath: plan.credentialPath,
|
|
||||||
...(email === undefined ? {} : { email }),
|
|
||||||
...(expected === undefined || expected === plan.bundle.toLowerCase()
|
|
||||||
? {}
|
|
||||||
: { identityMismatch: expected }),
|
|
||||||
tightened,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every entry in a harness's auth root, alias included, with enrollment state. */
|
|
||||||
export function listBundles(userHome: string, harness: CredentialHarness): BundleInfo[] {
|
|
||||||
const root = authRoot(userHome, harness);
|
|
||||||
const info = lstatIfPresent(root);
|
|
||||||
if (!info) return [];
|
|
||||||
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'unsafe-shape',
|
|
||||||
`${harness} auth root must be a real, non-symlink directory: ${root}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries: BundleInfo[] = [];
|
|
||||||
for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) =>
|
|
||||||
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
|
||||||
)) {
|
|
||||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
||||||
const path = join(root, entry.name);
|
|
||||||
let resolved: string;
|
|
||||||
try {
|
|
||||||
resolved = realpathSync(path);
|
|
||||||
} catch {
|
|
||||||
// A dangling alias is real state worth showing rather than a reason to fail the listing.
|
|
||||||
entries.push({ name: entry.name, path, resolved: path, alias: true, enrolled: false });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const alias = entry.isSymbolicLink();
|
|
||||||
const credential = join(resolved, CREDENTIAL_FILE_NAMES[harness]);
|
|
||||||
const email = readBundleIdentity(resolved, harness);
|
|
||||||
entries.push({
|
|
||||||
name: entry.name,
|
|
||||||
path,
|
|
||||||
resolved,
|
|
||||||
alias,
|
|
||||||
...(alias ? { target: resolved.slice(resolved.lastIndexOf(sep) + 1) } : {}),
|
|
||||||
enrolled: lstatIfPresent(credential)?.isFile() === true,
|
|
||||||
...(email === undefined ? {} : { email }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Point the movable `primary` alias at a real bundle.
|
|
||||||
*
|
|
||||||
* Relative so the whole `~/.mosaic` tree stays relocatable, and replaced rather than followed
|
|
||||||
* so retargeting never writes through into the old bundle.
|
|
||||||
*/
|
|
||||||
export function setDefaultBundle(
|
|
||||||
userHome: string,
|
|
||||||
harness: CredentialHarness,
|
|
||||||
bundle: string,
|
|
||||||
): string {
|
|
||||||
assertSafeBundleName(bundle);
|
|
||||||
if (bundle === PRIMARY_ALIAS) {
|
|
||||||
throw new AuthBundleError('invalid-request', `the ${PRIMARY_ALIAS} alias cannot target itself`);
|
|
||||||
}
|
|
||||||
const root = authRoot(userHome, harness);
|
|
||||||
const target = join(root, bundle);
|
|
||||||
const info = lstatIfPresent(target);
|
|
||||||
if (!info) {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'bundle-not-found',
|
|
||||||
`no such bundle: ${target} — enroll it first: mosaic auth enroll --harness ${harness} --bundle ${bundle}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'unsafe-shape',
|
|
||||||
`the ${PRIMARY_ALIAS} alias may only target a real bundle directory: ${target}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const alias = join(root, PRIMARY_ALIAS);
|
|
||||||
const existing = lstatIfPresent(alias);
|
|
||||||
if (existing && !existing.isSymbolicLink()) {
|
|
||||||
throw new AuthBundleError(
|
|
||||||
'unsafe-shape',
|
|
||||||
`a real directory occupies the ${PRIMARY_ALIAS} alias path and will not be deleted: ${alias}. Move it aside, or enroll under its own name.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (existing) rmSync(alias);
|
|
||||||
symlinkSync(bundle, alias);
|
|
||||||
return alias;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* How each harness reaches the credential stored in its auth bundle.
|
|
||||||
*
|
|
||||||
* Scaffolding and launch both act on this, so it lives in one module: a seat whose
|
|
||||||
* scaffold planted a credential symlink that launch never maintains (or the reverse)
|
|
||||||
* fails in a way that only shows up at the first token refresh.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Mirrors RuntimeName in commands/launch.ts; assignability is asserted there. */
|
|
||||||
export type CredentialHarness = 'claude' | 'codex' | 'opencode' | 'pi';
|
|
||||||
|
|
||||||
/** Credential file each harness reads, relative to its credential directory. */
|
|
||||||
export const CREDENTIAL_FILE_NAMES: Record<CredentialHarness, string> = {
|
|
||||||
claude: '.credentials.json',
|
|
||||||
pi: 'auth.json',
|
|
||||||
codex: 'auth.json',
|
|
||||||
opencode: 'auth.json',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Harnesses that can be pointed at a shared credential directory by environment,
|
|
||||||
* and the variable that does it.
|
|
||||||
*
|
|
||||||
* Claude Code saves credentials by writing a sibling temp file and rename()-ing it
|
|
||||||
* over the target. rename() replaces a symlink rather than following it, so a managed
|
|
||||||
* link at the seat's credential path is destroyed by the first token refresh and the
|
|
||||||
* seat silently forks its credentials. CLAUDE_SECURESTORAGE_CONFIG_DIR resolves the
|
|
||||||
* credential directory independently of CLAUDE_CONFIG_DIR, which keeps both the temp
|
|
||||||
* file and the rename inside the bundle where they belong. Evidence:
|
|
||||||
* docs/reports/harness/claude-credential-write-path-2026-08-14.md (jarvis-brain).
|
|
||||||
*
|
|
||||||
* The value is always an absolute bundle path. Claude reads an empty value as
|
|
||||||
* ~/.claude — the operator's own account — so an empty value must never be exported.
|
|
||||||
*
|
|
||||||
* Harnesses absent from this map keep the managed-link mechanism.
|
|
||||||
*/
|
|
||||||
export const CREDENTIAL_DIR_ENV: Partial<Record<CredentialHarness, string>> = {
|
|
||||||
claude: 'CLAUDE_SECURESTORAGE_CONFIG_DIR',
|
|
||||||
};
|
|
||||||
|
|
||||||
/** True when the harness reaches its bundle by environment instead of a seat-local link. */
|
|
||||||
export function sharesCredentialDirByEnv(harness: CredentialHarness): boolean {
|
|
||||||
return CREDENTIAL_DIR_ENV[harness] !== undefined;
|
|
||||||
}
|
|
||||||
@@ -1,346 +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';
|
|
||||||
|
|
||||||
import { CREDENTIAL_FILE_NAMES, sharesCredentialDirByEnv } from './credential-sharing.js';
|
|
||||||
|
|
||||||
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 = CREDENTIAL_FILE_NAMES[harness];
|
|
||||||
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 sharesByEnv = sharesCredentialDirByEnv(harness);
|
|
||||||
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) },
|
|
||||||
],
|
|
||||||
...(sharesByEnv
|
|
||||||
? []
|
|
||||||
: ([[join(homeName, credentialName), { type: 'symlink', target: credentialTarget }]] as [
|
|
||||||
string,
|
|
||||||
ExpectedFile,
|
|
||||||
][])),
|
|
||||||
[
|
|
||||||
join(homeName, '.mosaic-managed-links.json'),
|
|
||||||
{
|
|
||||||
type: 'file',
|
|
||||||
content: json({ links: sharesByEnv ? {} : { [credentialLink]: credentialTarget } }),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
];
|
|
||||||
if (harness === 'claude') {
|
|
||||||
entries.push([
|
|
||||||
join(homeName, '.claude.json'),
|
|
||||||
{ type: 'file', content: json(onboardingState(mosaicHome)) },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
const files = new Map<string, ExpectedFile>(entries);
|
|
||||||
|
|
||||||
// Seats scaffolded before the harness moved to an environment-shared credential
|
|
||||||
// directory still hold a credential symlink and name it in their manifest. The link
|
|
||||||
// is inert once the harness resolves its credential directory from the environment,
|
|
||||||
// so it is tolerated rather than reported as a foreign file or silently rewritten.
|
|
||||||
const legacyCredentialShape = sharesByEnv
|
|
||||||
? {
|
|
||||||
path: join(homeName, credentialName),
|
|
||||||
manifestPath: join(homeName, '.mosaic-managed-links.json'),
|
|
||||||
manifestContent: json({ links: { [credentialLink]: credentialTarget } }),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const differences = await findDifferences(agentDir, files, legacyCredentialShape);
|
|
||||||
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 };
|
|
||||||
|
|
||||||
interface LegacyCredentialShape {
|
|
||||||
/** Seat-relative path of the now-unused credential symlink. */
|
|
||||||
readonly path: string;
|
|
||||||
readonly manifestPath: string;
|
|
||||||
/** Manifest content written when that link was still maintained. */
|
|
||||||
readonly manifestContent: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findDifferences(
|
|
||||||
agentDir: string,
|
|
||||||
expected: ReadonlyMap<string, ExpectedFile>,
|
|
||||||
legacy?: LegacyCredentialShape,
|
|
||||||
): 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()) {
|
|
||||||
if (legacy && path === legacy.path) continue;
|
|
||||||
const required = expected.get(path);
|
|
||||||
if (!required) {
|
|
||||||
differences.push(path);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const info = await lstat(join(agentDir, path));
|
|
||||||
if (required.type === 'file') {
|
|
||||||
const content = info.isFile() ? await readFile(join(agentDir, path), 'utf8') : undefined;
|
|
||||||
const acceptable =
|
|
||||||
legacy && path === legacy.manifestPath
|
|
||||||
? [required.content, legacy.manifestContent]
|
|
||||||
: [required.content];
|
|
||||||
if (
|
|
||||||
!info.isFile() ||
|
|
||||||
info.isSymbolicLink() ||
|
|
||||||
content === undefined ||
|
|
||||||
!acceptable.includes(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)) {
|
|
||||||
throw new FleetAgentScaffoldError(
|
|
||||||
'invalid-request',
|
|
||||||
`canonical Claude settings must be a JSON object: ${settingsPath}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The shipped settings.json has no mcpServers key at all, so demanding one refused to
|
|
||||||
// scaffold any Claude seat on a clean install. Absent and empty mean the same thing here:
|
|
||||||
// no MCP servers. A present-but-wrong-typed key is still an error -- that is a real
|
|
||||||
// mistake in the file rather than a section the author had nothing to put in.
|
|
||||||
const servers = 'mcpServers' in authored ? authored.mcpServers : {};
|
|
||||||
if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) {
|
|
||||||
throw new FleetAgentScaffoldError(
|
|
||||||
'invalid-request',
|
|
||||||
`canonical Claude settings have a non-object mcpServers: ${settingsPath}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { hasCompletedOnboarding: true, theme: 'dark', mcpServers: servers };
|
|
||||||
}
|
|
||||||
|
|
||||||
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