Compare commits
4 Commits
main
...
feat/mos-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7302a94c3 | ||
|
|
894434ce1a | ||
|
|
d190732a55 | ||
|
|
8599fd27d8 |
@@ -1,12 +1,5 @@
|
|||||||
# Documentation Sitemap
|
# Documentation Sitemap
|
||||||
|
|
||||||
## Fleet configuration management
|
|
||||||
|
|
||||||
- [Generated environment boundary](fleet/reference/generated-env-boundary.md) — roster-derived launch projection, strict local data, legacy quarantine, and downstream interface evidence.
|
|
||||||
- [Roster v2 structural contract](fleet/reference/roster-v2-fields.md) — local-tmux schema v2 parsing and structural validation.
|
|
||||||
- [Role classes and authority](fleet/reference/role-classes.md) — canonical role resolver and protected authority boundaries.
|
|
||||||
- [Executable asset dispositions](fleet/migration/example-profile-disposition.md) — shipped v1 fixture/profile/service validation posture.
|
|
||||||
|
|
||||||
## Official channel plugins
|
## Official channel plugins
|
||||||
|
|
||||||
- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts.
|
- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts.
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
||||||
|
|
||||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
||||||
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
|
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||||
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
|
| FCM-M0-001 | in-progress | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | M0 exit: approved docs; every shipped example/profile/service preset classified; docs-only PR |
|
||||||
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
|
| FCM-M1-001 | not-started | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | codex | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | No lifecycle, remote, connector, secret, channel, or gateway work |
|
||||||
| FCM-M1-002 | in-progress | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Started 2026-07-14 from `aa5b43b`; one shared resolver only; validator certificate-only; merge-gate sole merge authority |
|
| FCM-M1-002 | not-started | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | codex | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Validator is certificate-only; merge-gate remains sole merge authority |
|
||||||
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement |
|
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement |
|
||||||
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only |
|
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only |
|
||||||
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start |
|
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start |
|
||||||
|
|||||||
@@ -1,77 +1,114 @@
|
|||||||
# Fleet Launch Runbook
|
# Fleet Launch Runbook
|
||||||
|
|
||||||
The local fleet roster is the sole writable desired-state authority for membership and launch policy.
|
How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to
|
||||||
Generated environment files are rebuildable projections, not an operator-editable command surface.
|
configure each one. The guiding principle: **one roster-driven launcher**. There is no bespoke
|
||||||
|
per-agent launch script; the roster plus per-agent `.env` files are the single source of launch
|
||||||
|
config.
|
||||||
|
|
||||||
## Launch chain
|
## The launch chain
|
||||||
|
|
||||||
| Layer | Responsibility |
|
| Layer | File | Responsibility |
|
||||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
| systemd unit | `mosaic-agent@<role>.service` | One templated unit per role; `ExecStart` runs the session launcher with the instance name `%i`. Defaults `MOSAIC_AGENT_RUNTIME=pi`, `MOSAIC_AGENT_NAME=%i`. |
|
||||||
| Projection writer | Renders deterministic `fleet/agents/<name>.env.generated` from the roster. |
|
| session launcher | `tools/fleet/start-agent-session.sh <role>` | Builds the launch command, opens the tmux pane, wires the heartbeat. |
|
||||||
| Optional local data | Reads a strict, data-only `fleet/agents/<name>.env.local`; it cannot shadow generated keys. |
|
| launch command | `mosaic yolo <runtime>` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. |
|
||||||
| systemd | Starts the launcher with `env -i` and fixed bootstrap data. It does not preload either environment file. |
|
| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. |
|
||||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
|
||||||
| runtime launch | Derives the fixed `mosaic yolo <runtime>` argument array from validated roster data, then seeds the runtime contract. |
|
|
||||||
|
|
||||||
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
Per-agent overrides live in `fleet/agents/<role>.env`, generated from `roster.yaml` by
|
||||||
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher.
|
||||||
secret-like key names, duplicate keys, comments, quoted/export syntax, and unsafe values are rejected.
|
|
||||||
|
|
||||||
## Generated and local files
|
## Worker launch path (default)
|
||||||
|
|
||||||
`<name>.env.generated` is complete, deterministic, and written only by Mosaic. Its ordered keys are:
|
1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`.
|
||||||
|
2. `generateAgentEnv` emits `fleet/agents/<role>.env` with `MOSAIC_AGENT_NAME`,
|
||||||
|
`MOSAIC_AGENT_RUNTIME`, and `MOSAIC_AGENT_MODEL`.
|
||||||
|
3. `start-agent-session.sh` has no `MOSAIC_AGENT_COMMAND` set, so it falls through to the default
|
||||||
|
(line ~44):
|
||||||
|
```sh
|
||||||
|
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
|
||||||
|
```
|
||||||
|
4. The launcher bakes `MOSAIC_AGENT_NAME` into the pane command (line ~118), so `composeContract`
|
||||||
|
can inject the Fleet-Comms cheat-sheet for that role.
|
||||||
|
|
||||||
```dotenv
|
That is the whole worker path: roster → `.env` → `mosaic yolo <runtime>` → seeded pane.
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
## Orchestrator fold (PATH A — ships today)
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
The orchestrator is **just another roster agent** launched through the canonical path — not a
|
||||||
MOSAIC_AGENT_REASONING=<roster reasoning>
|
snowflake script.
|
||||||
MOSAIC_AGENT_TOOL_POLICY=<roster tool policy>
|
|
||||||
MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
| Piece | Value |
|
||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
| ------------------ | ----------------------------------- |
|
||||||
|
| host-side launcher | `orchestrator-launch.sh` |
|
||||||
|
| systemd unit | `mosaic-fleet-orchestrator.service` |
|
||||||
|
| tmux session | `orchestrator` (role-named) |
|
||||||
|
|
||||||
|
Set its launch command via `fleet/agents/orchestrator.env`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MOSAIC_AGENT_COMMAND='mosaic yolo claude --channels plugin:discord@<channel>'
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. `mosaic fleet add`
|
When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]`
|
||||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped
|
||||||
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude`
|
||||||
it has no generated-launch adapter and cannot be added through this path.
|
(rather than a raw `claude` invocation) is what gives the orchestrator the same full
|
||||||
|
`composeContract` seeding + Fleet-Comms cheat-sheet as every worker, with `--channels` and any
|
||||||
|
other flags passed straight through to the `claude` binary.
|
||||||
|
|
||||||
`<name>.env.local` is optional and may contain only non-secret machine data:
|
## Launch gotchas
|
||||||
|
|
||||||
- `MOSAIC_RUNTIME_BIN`
|
1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do
|
||||||
- `MOSAIC_HEARTBEAT_RUN_DIR`
|
**not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both.
|
||||||
- `MOSAIC_HEARTBEAT_INTERVAL`
|
Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo
|
||||||
- `MOSAIC_CLAUDE_JSON`
|
`mosaic claude --permission-mode bypassPermissions …`. Never mix the two.
|
||||||
- `CLAUDE_CONFIG_DIR`
|
2. **`MOSAIC_AGENT_NAME` must reach the pane.** The launcher bakes it from the instance name, and
|
||||||
|
`composeContract` gates the Fleet-Comms block on it (`launch.ts`, in `composeContract`) — **and**
|
||||||
|
the role must be a member of `roster.yaml`, or the block resolves empty.
|
||||||
|
3. **`launchRuntime` guards.** `mosaic yolo claude` runs `checkSoul` / `checkRuntime` /
|
||||||
|
`checkSequentialThinking`. The host needs `SOUL.md` and the sequential-thinking MCP, or the
|
||||||
|
launch aborts (a raw `claude` invocation skipped these checks). Dry-run the composed command in a
|
||||||
|
throwaway tmux session before swapping a live launcher.
|
||||||
|
|
||||||
Paths must be safe absolute paths and the heartbeat interval must be a positive integer. Projection,
|
## Why per-agent `.env` survives upgrades (#632)
|
||||||
local, and quarantine files must be private regular files; the managed directories must be real,
|
|
||||||
private, non-symlink paths. Violations fail closed before tmux interaction.
|
|
||||||
|
|
||||||
## Legacy input and diagnostics
|
`install.sh` `PRESERVE_PATHS` includes `fleet/*.yaml`, `fleet/agents`, and `fleet/run`, so
|
||||||
|
`mosaic update`'s framework re-seed **preserves** your roster and per-agent `.env` overrides
|
||||||
|
(glob-aware `cp` fallback; matching TS parity in `file-adapter.ts`). Before #632, an auto re-seed
|
||||||
|
could wipe them — which is exactly why PATH A's `.env` override is safe to rely on now.
|
||||||
|
|
||||||
A legacy `<name>.env` is input only during projection generation. Roster-owned keys are regenerated;
|
## Inspecting the comms wiring
|
||||||
valid allowed local data can move to `.env.local`; invalid legacy input is privately retained at
|
|
||||||
`<name>.env.quarantine`. Neither legacy nor quarantine files are launch authority.
|
|
||||||
|
|
||||||
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
- `mosaic fleet comms-block <role>` prints the Fleet-Comms cheat-sheet a given role receives at
|
||||||
text, credentials, or other values.
|
launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the
|
||||||
|
FLIP / `--verify` conventions. `--host <h>` previews a cross-host view. An unknown role or missing
|
||||||
|
roster **fails loud** (stderr + non-zero exit), so a typo is never a silent no-op.
|
||||||
|
- Versus `mosaic compose-contract <runtime>`: that emits the **whole** system prompt and reads the
|
||||||
|
role from `MOSAIC_AGENT_NAME` (a full-prompt smoke test). `comms-block` is the targeted,
|
||||||
|
explicit-arg, comms-only view — e.g. `mosaic fleet comms-block coder0-0` to preview a peer.
|
||||||
|
|
||||||
## Launch and stop behavior
|
## North Star / future direction
|
||||||
|
|
||||||
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
**Vision:** a webUI lets the user edit each agent's launch config — switch **harness**
|
||||||
checks the exact `=<agent-name>` tmux target; it never uses an ambient socket or fuzzy session match.
|
(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels**
|
||||||
The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative;
|
override — with no terminal.
|
||||||
the shell sidecar only provides fallback state when the native marker is stale or absent.
|
|
||||||
|
|
||||||
`mosaic fleet comms-block <role>` can inspect the role's resolved Fleet-Comms block. It is a read-only
|
**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the
|
||||||
inspection tool and fails loudly for an unknown role or missing roster.
|
existing roster-driven launcher. Field-by-field status today:
|
||||||
|
|
||||||
## Current M2 boundary
|
| Launch-config field | Roster-native today? | Mechanism / gap |
|
||||||
|
| ------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **harness** (`runtime`) | ✅ end-to-end | `roster.runtime` → `generateAgentEnv` emits `MOSAIC_AGENT_RUNTIME` → launcher line 44. UI just writes the field. |
|
||||||
|
| **model** (`model_hint`) | ✅ end-to-end | `roster.model_hint` → `MOSAIC_AGENT_MODEL` → launcher line 44 `--model`. UI just writes the field. |
|
||||||
|
| **yolo** | ❌ new | Launcher line 44 **hardcodes** `mosaic yolo`. A non-yolo toggle needs a roster `yolo` field → emit `MOSAIC_AGENT_YOLO` → make line 44 conditional. |
|
||||||
|
| **command / channels** | ❌ new | `MOSAIC_AGENT_COMMAND` is **consumed** (launcher line ~12) but `generateAgentEnv` does not emit it. Needs a roster `command`/`channels` field → emitted. |
|
||||||
|
|
||||||
FCM-M2-001 supplies generated/local parsing, validation, projection, quarantine, and launch-boundary
|
**The arc:**
|
||||||
evidence only. It does not authorize roster CRUD expansion, reconciliation, lifecycle changes, remote
|
|
||||||
or connector mutation, site canaries, or migration. M3 must establish the local reconcile/lifecycle
|
- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632.
|
||||||
path; M4 separately provides migration preview, canary, and rollback gates.
|
- **B** — roster-native launch-config: harness + model are already there; add the **yolo** toggle
|
||||||
|
(line-44 conditional) and **command/channels** emission to complete the data model.
|
||||||
|
- **webUI** — binds dropdowns/toggles directly to those four roster fields.
|
||||||
|
|
||||||
|
PATH A's `.env` override is the **manual form** of exactly what PATH B makes roster-native and the
|
||||||
|
webUI edits — one continuous arc, not three separate features. PATH B is tracked as #636.
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ The v2 compiler may not silently accept an unresolved class. Before M1 exits, ev
|
|||||||
below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or
|
below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or
|
||||||
retired with a replacement/deprecation note. Class resolution must use the existing
|
retired with a replacement/deprecation note. Class resolution must use the existing
|
||||||
profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a
|
profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a
|
||||||
parallel resolver. The current executable implementation and per-artifact outcomes are recorded in
|
parallel resolver.
|
||||||
[the disposition evidence](./migration/example-profile-disposition.md).
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
# Create, Inspect, Update, and Delete a Local Fleet Agent
|
|
||||||
|
|
||||||
Use the local roster-v2 control plane only. These commands change desired state and derived environment projections; they never start, stop, reconcile, inspect, or otherwise act on systemd, tmux, sessions, or runtimes.
|
|
||||||
|
|
||||||
## Read and plan first
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mosaic fleet get <name>
|
|
||||||
mosaic fleet plan create --expected-generation <n> --agent '<json>'
|
|
||||||
mosaic fleet plan update <name> --expected-generation <n> --agent '<json>'
|
|
||||||
mosaic fleet plan delete <name> --expected-generation <n>
|
|
||||||
```
|
|
||||||
|
|
||||||
`plan create` takes the name from `--agent`. `plan update` and `plan delete` require the target name immediately after the operation. A plan is deterministic and side-effect free: it validates the complete proposed roster and projection targets without changing files. Use `--dry-run` on `create`, `update`, or `delete` for the same no-write result.
|
|
||||||
|
|
||||||
Every successful command prints JSON. `get` returns `{ "generation", "agent" }`; mutation results contain `plan`, `applied`, `authoritativeRoster`, and `projections`.
|
|
||||||
|
|
||||||
## Create safely
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mosaic fleet create --expected-generation 7 --agent '{
|
|
||||||
"name":"coder0",
|
|
||||||
"alias":"Coder 0",
|
|
||||||
"className":"code",
|
|
||||||
"runtime":"pi",
|
|
||||||
"provider":"openai",
|
|
||||||
"model":"gpt-5.6-sol",
|
|
||||||
"reasoning":"high",
|
|
||||||
"toolPolicy":"code",
|
|
||||||
"workingDirectory":"/srv/mosaic",
|
|
||||||
"persistentPersona":false,
|
|
||||||
"resetBetweenTasks":true,
|
|
||||||
"launch":{"yolo":true}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Create defaults to `enabled: true` and `desired_state: stopped`. It does not start a process. Add `--persisted-start` only to persist `desired_state: running`; that still does not start a runtime in this M2 command. The JSON payload is an allowlist of the roster-v2 fields shown above plus `launch.yolo`; command, channel, secret-reference, and other unknown keys are rejected rather than ignored. The JSON error exposes only a stable code, never the rejected value.
|
|
||||||
|
|
||||||
## Update and delete safely
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mosaic fleet update coder0 --expected-generation 8 --agent '<complete JSON agent payload>'
|
|
||||||
mosaic fleet delete coder0 --expected-generation 9
|
|
||||||
```
|
|
||||||
|
|
||||||
Updates require a complete agent JSON payload and preserve the stable name. Delete removes only the exact roster-owned `coder0.env.generated` projection. It retains `coder0.env.local`, legacy `coder0.env`, `coder0.env.quarantine`, and every unrelated projection. A delete dry-run leaves all of those files byte-identical.
|
|
||||||
|
|
||||||
## Handle generation conflicts
|
|
||||||
|
|
||||||
Every mutation requires the current authoritative `--expected-generation`. A stale value returns JSON `error.code: "stale-generation"` with a non-zero exit. Reload with `mosaic fleet get <name>` or reread the roster, plan again using the returned generation, then retry. A concurrent mutation returns `concurrent-mutation`; do not force or bypass the lock.
|
|
||||||
|
|
||||||
## Interpret partial failures
|
|
||||||
|
|
||||||
The roster is authoritative and is written before derived projections. A late projection I/O failure returns non-zero with redacted, actionable JSON:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"applied": false,
|
|
||||||
"authoritativeRoster": "committed",
|
|
||||||
"projections": "incomplete",
|
|
||||||
"recovery": {
|
|
||||||
"code": "projection-apply-failed",
|
|
||||||
"action": "regenerate-projections-from-roster"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This is not a rollback and not a no-op: reload the roster because its generation and membership were committed, regenerate projections from that roster, then plan a new mutation. Recovery output never contains environment values, credentials, or command text.
|
|
||||||
|
|
||||||
## Exit and boundary behavior
|
|
||||||
|
|
||||||
Handled validation errors and partial projection failures exit non-zero. `plan`/`--dry-run` and normal mutation JSON make the state explicit; scripts should use both the exit code and `authoritativeRoster`/`projections`, not `applied` alone.
|
|
||||||
|
|
||||||
The commands operate only on `<mosaic-home>/fleet/roster.yaml`, the local roster desired-state authority. They do not accept arbitrary commands, channels, secrets, remote/connector actions, migration/canary actions, or runtime lifecycle operations.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Customize Fleet Roles
|
|
||||||
|
|
||||||
Mosaic resolves persona contracts through two layers:
|
|
||||||
|
|
||||||
1. `fleet/roles/<canonical-class>.md` — seeded baseline contract.
|
|
||||||
2. `fleet/roles.local/<canonical-class>.md` — operator override or custom role; this layer wins.
|
|
||||||
|
|
||||||
The same shared resolver is used by profile validation, provisioning, roster-v2 semantic validation,
|
|
||||||
and launch-time persona injection.
|
|
||||||
|
|
||||||
## Override a baseline role
|
|
||||||
|
|
||||||
Create a readable Markdown contract under `roles.local` with the canonical filename and class marker:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Code — local role definition
|
|
||||||
|
|
||||||
The local code role (`class: code`) follows the operator's repository conventions.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save it as `fleet/roles.local/code.md`. Do not edit generated or seeded baseline assets when the goal
|
|
||||||
is a durable local customization.
|
|
||||||
|
|
||||||
Legacy aliases canonicalize before lookup. Therefore `roles.local/implementer.md` does not override
|
|
||||||
`code`; use `roles.local/code.md`. See [Legacy Fleet Class Aliases](../migration/legacy-class-aliases.md).
|
|
||||||
|
|
||||||
## Add a custom class
|
|
||||||
|
|
||||||
A custom class remains supported when a readable contract exists for the exact identifier:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
# Release notes — local role definition
|
|
||||||
|
|
||||||
The release-notes role (`class: release-notes`) prepares operator-reviewed release copy.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save it as `fleet/roles.local/release-notes.md`, then reference `class: release-notes` and a matching
|
|
||||||
`tool_policy: release-notes` in roster v2. Adding only a `LIBRARY.md` row is insufficient.
|
|
||||||
|
|
||||||
Names such as `worker`, `analyst`, and `canary` are not built-in aliases; they need genuine custom
|
|
||||||
contracts. `agents[].alias`, Tess, and Ultron are display names and cannot select a class.
|
|
||||||
|
|
||||||
## Validation and authority boundaries
|
|
||||||
|
|
||||||
Semantic validation reads the winning contract and rejects missing, unreadable, or empty files.
|
|
||||||
Protected authority is derived from canonical class metadata in code, never from role prose. A custom
|
|
||||||
contract cannot claim merge, validation-certificate, orchestration, lease, or interaction authority.
|
|
||||||
|
|
||||||
Roster v2 also fails closed when a protected class and tool policy do not match after canonicalization,
|
|
||||||
or when an unprotected class claims a protected tool policy. The legacy `operator-interaction` policy
|
|
||||||
canonicalizes to `interaction`.
|
|
||||||
|
|
||||||
Role customization does not issue leases, store validation certificates, mutate credentials, or
|
|
||||||
change lifecycle state.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Executable Fleet Example, Profile, and Service-Preset Dispositions
|
|
||||||
|
|
||||||
**Issue:** #758 · **Card:** FCM-M1-003 · **Status:** M1 executable disposition evidence
|
|
||||||
|
|
||||||
This document records the executable disposition for every currently shipped fleet YAML artifact.
|
|
||||||
The authoritative baseline classification remains the
|
|
||||||
[legacy inventory](../LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md). The executable guard is
|
|
||||||
`packages/mosaic/src/fleet/example-profile-dispositions.ts`; its test fails if a shipped YAML
|
|
||||||
artifact is added, removed, or left without one of the dispositions below.
|
|
||||||
|
|
||||||
## Disposition rules
|
|
||||||
|
|
||||||
- **Explicit v1 fixture:** the artifact is loaded through the existing v1 roster parser and must
|
|
||||||
declare `version: 1`. It remains a compatibility fixture; it is not silently treated as a v2
|
|
||||||
roster or given inferred aliases.
|
|
||||||
- **Canonical profile:** the artifact is loaded through `loadProfiles`, which uses the shared
|
|
||||||
baseline-plus-`roles.local` persona resolver and rejects unreadable or unresolved classes.
|
|
||||||
- **Canonical service policy:** the artifact is loaded through the operator-interaction service
|
|
||||||
policy reader and provisioned with a generic supplied identity. It validates its runtime, model,
|
|
||||||
reasoning, and legacy tool-policy compatibility without hardcoding a product identity.
|
|
||||||
|
|
||||||
No artifact is retired in this card. A later retirement requires both a replacement link and a
|
|
||||||
visible deprecation note; the executable guard must then record the new disposition before the
|
|
||||||
artifact can be removed.
|
|
||||||
|
|
||||||
## Shipped artifacts
|
|
||||||
|
|
||||||
| Artifact | Disposition | Executable path | Compatibility notes |
|
|
||||||
| ------------------------------------ | ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
||||||
| `examples/coding.yaml` | Explicit v1 fixture | v1 roster parser | Retains approved `implementer` and `reviewer` compatibility inputs. |
|
|
||||||
| `examples/general.yaml` | Explicit v1 fixture | v1 roster parser | Retains unresolved `worker` without an inferred canonical role. |
|
|
||||||
| `examples/hybrid.yaml` | Explicit v1 fixture | v1 roster parser | Retains `implementer`, `reviewer`, and resolver-dependent `researcher`. |
|
|
||||||
| `examples/local-canary.yaml` | Explicit v1 fixture | v1 roster parser | Retains the local-tmux canary topology. |
|
|
||||||
| `examples/minimal.yaml` | Explicit v1 fixture | v1 roster parser | Retains `canary` without an inferred canonical role. |
|
|
||||||
| `examples/operator-interaction.yaml` | Explicit v1 fixture | v1 roster parser | Keeps Tess only as an example instance name; `operator-interaction` remains compatibility input. |
|
|
||||||
| `examples/research.yaml` | Explicit v1 fixture | v1 roster parser | Retains resolver-dependent `researcher` and `analyst`. |
|
|
||||||
| `profiles/business.yaml` | Canonical profile | shared profile/persona resolver | Every referenced business class must resolve to a readable contract. |
|
|
||||||
| `profiles/marketing.yaml` | Canonical profile | shared profile/persona resolver | Every referenced marketing class must resolve to a readable contract. |
|
|
||||||
| `profiles/personal-assistant.yaml` | Canonical profile | shared profile/persona resolver | No interaction equivalence is inferred. |
|
|
||||||
| `profiles/research.yaml` | Canonical profile | shared profile/persona resolver | Every research class must resolve to a readable contract. |
|
|
||||||
| `profiles/software-delivery.yaml` | Canonical profile | shared profile/persona resolver | Retains the governance profile; authority validation remains FCM-M1-002 evidence. |
|
|
||||||
| `services/operator-interaction.yaml` | Canonical service policy | service-policy reader/provisioner | Generic provisioning supplies the instance name; the policy itself never names Tess. |
|
|
||||||
|
|
||||||
## Running the guard
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
The guard is intentionally limited to shipped assets and validation. It does not generate
|
|
||||||
environment files, mutate a roster, reconcile a fleet, migrate an installed roster, or launch an
|
|
||||||
agent.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Legacy Fleet Class Aliases
|
|
||||||
|
|
||||||
Fleet class compatibility is intentionally narrow. The shared resolver accepts exactly three legacy
|
|
||||||
class names and converts them to canonical classes before persona lookup:
|
|
||||||
|
|
||||||
| Legacy value | Canonical value | Migration action |
|
|
||||||
| ---------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `implementer` | `code` | Replace class and tool-policy references with `code`. |
|
|
||||||
| `reviewer` | `review` | Replace class and tool-policy references with `review`. |
|
|
||||||
| `operator-interaction` | `interaction` | Replace class and roster-v2 tool-policy references with `interaction`. The legacy service artifact remains compatible. |
|
|
||||||
|
|
||||||
Alias support preserves existing inputs while provisioning and typed semantic output use canonical
|
|
||||||
identities. Requested and canonical class values remain separately observable during semantic
|
|
||||||
validation.
|
|
||||||
|
|
||||||
## Lookup and override behavior
|
|
||||||
|
|
||||||
Canonicalization precedes baseline and `roles.local` lookup. A legacy-named override such as
|
|
||||||
`roles.local/implementer.md` is not a separate authority and is not selected for an `implementer`
|
|
||||||
request. Customize the canonical role instead, for example `roles.local/code.md`.
|
|
||||||
|
|
||||||
The compatibility file `operator-interaction.md` remains shipped, but `interaction` is the canonical
|
|
||||||
role class. Tess is an example display name only.
|
|
||||||
|
|
||||||
## Unresolved and custom classes
|
|
||||||
|
|
||||||
No names are inferred from historical usage, instance names, or similar wording. `worker`, `analyst`,
|
|
||||||
`canary`, Tess, and Ultron are not aliases. An otherwise unknown class is accepted only if the shared
|
|
||||||
resolver can read an actual baseline or `roles.local` contract for that exact class. A `LIBRARY.md`
|
|
||||||
row without a readable contract fails semantic validation.
|
|
||||||
|
|
||||||
Custom classes receive no protected authority implicitly. Protected class/tool-policy mismatches
|
|
||||||
fail closed.
|
|
||||||
|
|
||||||
## Retirement guidance
|
|
||||||
|
|
||||||
New configuration should emit canonical values. Existing inputs may use the three aliases during the
|
|
||||||
compatibility period, but operators should migrate class and tool-policy fields together. Do not
|
|
||||||
create new legacy-named role overrides; move their intended content to the canonical filename and
|
|
||||||
validate the roster/profile before removing the old artifact.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Local Fleet Agent Mutations
|
|
||||||
|
|
||||||
FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operations. They only change desired state and derived environment projections. They never start, stop, inspect, reconcile, or otherwise act on runtimes, systemd units, tmux sessions, or heartbeats.
|
|
||||||
|
|
||||||
## CLI contract
|
|
||||||
|
|
||||||
The commands operate only on the canonical `<mosaic-home>/fleet/roster.yaml` v2 authority and print one JSON object to stdout. `--agent` is a JSON object with the roster agent fields expressed as `className`, `toolPolicy`, `workingDirectory`, `persistentPersona`, `resetBetweenTasks`, and `launch: { "yolo": boolean }`.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
mosaic fleet get <name>
|
|
||||||
mosaic fleet plan <create|update|delete> [name] --expected-generation <n> [--agent '<json>'] [--persisted-start]
|
|
||||||
mosaic fleet create --expected-generation <n> --agent '<json>' [--dry-run] [--persisted-start]
|
|
||||||
mosaic fleet update <name> --expected-generation <n> --agent '<json>' [--dry-run]
|
|
||||||
mosaic fleet delete <name> --expected-generation <n> [--dry-run]
|
|
||||||
```
|
|
||||||
|
|
||||||
`get` returns the authoritative generation and the selected agent. `plan create` derives its name from `--agent`; `plan update <name>` and `plan delete <name>` require the target name. `--agent` accepts only the documented roster-v2 request fields and `launch.yolo`; unknown keys such as commands, channels, or secret references are rejected. Rejection diagnostics return only the stable `invalid-request` code and never echo a rejected value. `plan` and `--dry-run` validate the complete proposed roster and projections but write neither the roster nor projections. `--persisted-start` is available only for a create request: it records `desired_state: running`, but does not start a process. Without it, create records `enabled: true` and `desired_state: stopped`. Handled failures return JSON with `error.code` and exit non-zero; unclassified validation/projection failures use the redacted `mutation-failed` code.
|
|
||||||
|
|
||||||
## Generation, validation, and idempotency
|
|
||||||
|
|
||||||
Each create, update, or delete request includes `expectedGeneration`. A request whose expected value differs from the authoritative roster generation fails with `stale-generation`; reload and retry with a newly computed plan. A private mutation lock rejects concurrent writers with `concurrent-mutation`.
|
|
||||||
|
|
||||||
`planFleetAgentMutation` is deterministic and side-effect free. `executeFleetAgentMutation` validates the complete proposed roster through the existing structural and shared persona resolver, prepares generated/local/quarantine projections, and writes the roster authority atomically before applying derived projections. Equivalent create retries and delete requests for an already-absent agent are idempotent no-ops.
|
|
||||||
|
|
||||||
Delete removes only the exact `<name>.env.generated` projection for the removed roster entry. Operator-owned `<name>.env.local`, legacy `<name>.env`, quarantine records, and unrelated projections remain untouched. An already-absent generated projection is treated as stale derived state, not as a failed mutation.
|
|
||||||
|
|
||||||
## Result and recovery
|
|
||||||
|
|
||||||
Mutation results are JSON-safe objects with `applied`, `authoritativeRoster`, `projections`, `plan`, and—only if a derived projection write fails after the authoritative roster write—a recovery object. `applied` is true only when every roster and derived-projection write completed. The explicit state fields prevent a partial result from being mistaken for a rollback or a no-op:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"applied": false,
|
|
||||||
"authoritativeRoster": "committed",
|
|
||||||
"projections": "incomplete",
|
|
||||||
"recovery": {
|
|
||||||
"code": "projection-apply-failed",
|
|
||||||
"action": "regenerate-projections-from-roster"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Dry-runs and idempotent no-ops report `authoritativeRoster: "unchanged"` and `projections: "not-applied"`; a complete mutation reports `"committed"` and `"complete"`. Recovery output identifies the authoritative roster path and regeneration action only. It never contains generated/local/quarantine values, credentials, or command text. A recovery result exits non-zero because the authoritative roster was persisted but derived projections require regeneration. Regenerate projections from the roster before attempting another mutation.
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
# Fleet Generated Environment Boundary
|
|
||||||
|
|
||||||
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** unreleased/card-local
|
|
||||||
|
|
||||||
The local fleet roster is the desired-state authority. A launch reads a deterministic,
|
|
||||||
roster-derived generated projection and an optional strictly data-only local file; neither file is
|
|
||||||
a second roster or a command configuration surface.
|
|
||||||
|
|
||||||
## Paths and ownership
|
|
||||||
|
|
||||||
For agent `<name>` under `<MOSAIC_HOME>/fleet/agents/`:
|
|
||||||
|
|
||||||
| Path | Owner | Purpose |
|
|
||||||
| ----------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `<name>.env.generated` | Mosaic projection writer | Complete deterministic launch data rendered from the authoritative roster. |
|
|
||||||
| `<name>.env.local` | Operator | Optional, constrained local machine data. It cannot shadow generated keys. |
|
|
||||||
| `<name>.env` | Legacy input only | Read once during projection generation, then regenerated/relocated or privately quarantined. It is never a launch authority. |
|
|
||||||
| `<name>.env.quarantine` | Mosaic quarantine | Mode-`0600` private record of forbidden legacy input; it is never read by the launcher. |
|
|
||||||
|
|
||||||
The systemd templates do not load either environment file. They invoke Bash with a fixed, cleared
|
|
||||||
bootstrap environment; the launcher reads and validates `.env.generated` and `.env.local` itself before
|
|
||||||
it queries, creates, or stops an exact tmux session. It does not `source`, `eval`, or execute an
|
|
||||||
environment-supplied command. Exact stop derives its socket from the same validated generated projection,
|
|
||||||
not from systemd or ambient environment data.
|
|
||||||
|
|
||||||
All projection, local, and quarantine files must be regular files with no group or world permissions.
|
|
||||||
The agent environment directory must also be a real, non-symlink private directory; it is validated
|
|
||||||
before either environment file is read or tmux is queried. Unsafe paths, symlinks, or permissions fail
|
|
||||||
closed. Diagnostics identify only a rule code, key name, and SHA-256 content hash; they never print
|
|
||||||
values, credential material, or command text.
|
|
||||||
|
|
||||||
## Allowed data
|
|
||||||
|
|
||||||
`.env.generated` is complete and ordered exactly as follows:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
|
||||||
MOSAIC_AGENT_REASONING=<roster reasoning>
|
|
||||||
MOSAIC_AGENT_TOOL_POLICY=<roster tool policy>
|
|
||||||
MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|
||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
|
||||||
```
|
|
||||||
|
|
||||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. `fleet add`
|
|
||||||
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
|
||||||
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
|
||||||
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
|
||||||
this projection path.
|
|
||||||
|
|
||||||
`.env.local` may contain only these non-secret data keys:
|
|
||||||
|
|
||||||
- `MOSAIC_RUNTIME_BIN`
|
|
||||||
- `MOSAIC_HEARTBEAT_RUN_DIR`
|
|
||||||
- `MOSAIC_HEARTBEAT_INTERVAL`
|
|
||||||
- `MOSAIC_CLAUDE_JSON`
|
|
||||||
- `CLAUDE_CONFIG_DIR`
|
|
||||||
|
|
||||||
Local paths must be safe absolute paths and the interval must be a positive integer. Comments,
|
|
||||||
quoted/export syntax, duplicate keys, unknown keys, generated-key shadowing, sensitive key names,
|
|
||||||
and `MOSAIC_AGENT_COMMAND` are rejected. The launcher derives the only executable command from the
|
|
||||||
validated runtime, model, and reasoning data; no arbitrary command compatibility path exists. When a
|
|
||||||
Pi runtime writes a fresh `<name>.hb.native` marker, its native heartbeat remains authoritative; the
|
|
||||||
shell sidecar resumes its `status=ok` fallback only after that marker is stale or absent.
|
|
||||||
|
|
||||||
## Legacy disposition
|
|
||||||
|
|
||||||
During projection generation, legacy roster-derived keys are regenerated from the roster. A valid
|
|
||||||
allowed local value is relocated to `.env.local`; forbidden, malformed, duplicate, sensitive, and
|
|
||||||
unknown legacy entries cause the legacy file to be moved to `.env.quarantine` and are represented by
|
|
||||||
sanitized diagnostics. This is deterministic and idempotent after the legacy file has been consumed.
|
|
||||||
|
|
||||||
## USC interface packet
|
|
||||||
|
|
||||||
This card does not add a USC site file, write a USC roster, or run a site canary. The following is the
|
|
||||||
consolidated downstream interface packet. Status is deliberately separated from checkout presence: no
|
|
||||||
product release version has been evidenced for this interface set.
|
|
||||||
|
|
||||||
| Interface | Canonical public path and version | Tracker/release status | Downstream limit |
|
|
||||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
||||||
| M1 structural compiler | `parseRosterV2` in `packages/mosaic/src/fleet/roster-v2.ts`; schema `docs/fleet/reference/roster-v2.schema.json`; roster `version: 2` | FCM-M1-001 is recorded done, merged as #764 (`aa5b43b`); no released product version is asserted here. | Parse YAML/JSON and canonicalize a supplied v2 site roster without writes. |
|
|
||||||
| M1 semantic resolver | `validateRosterV2Semantics` in `packages/mosaic/src/fleet/roster-v2.ts`; baseline `framework/fleet/roles/` plus `roles.local/` | FCM-M1-002 remains `in-progress` in `docs/TASKS.md`; unreleased. | Reuse the shared resolver only; no parallel role resolver or lifecycle action. |
|
|
||||||
| M1 disposition evidence | `packages/mosaic/src/fleet/example-profile-dispositions.ts`; `docs/fleet/migration/example-profile-disposition.md`; retained fixture `version: 1` | FCM-M1-003 remains `not-started` in `docs/TASKS.md`; unreleased even though these checkout artifacts are inspectable. | Inspect fixture/profile/service disposition evidence only; it is not migration authorization. |
|
|
||||||
| M2 generated boundary | `packages/mosaic/src/fleet/generated-env-boundary.ts`; generated projection contract in this document | FCM-M2-001 card-local and uncommitted; unreleased. | Render/write a roster-derived projection; local input is never authority. |
|
|
||||||
|
|
||||||
The canonical source remains `<MOSAIC_HOME>/fleet/roster.yaml` for the current local fleet path.
|
|
||||||
Generated environment data is a rebuildable projection, not an operator-editable source of membership,
|
|
||||||
runtime policy, or lifecycle state.
|
|
||||||
|
|
||||||
**Corrected downstream gates:** M2 supplies only parse/validation/projection evidence and does not
|
|
||||||
permit a USC site canary, reconciliation, or lifecycle mutation. M3 must first define and validate the
|
|
||||||
canonical local reconcile/lifecycle path. M4 then supplies preview/migration and its separate
|
|
||||||
canary/rollback gates; only after those M3 and M4 gates may a site migration or canary be considered.
|
|
||||||
This card authorizes none of those actions.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Fleet Role Classes and Authority
|
|
||||||
|
|
||||||
A fleet role class is a machine identity resolved from the persona library. Resolution uses the
|
|
||||||
canonical class before consulting the baseline `fleet/roles/` and operator `fleet/roles.local/`
|
|
||||||
layers. A readable role contract is required; an index entry alone is not semantic success.
|
|
||||||
|
|
||||||
## Canonicalization
|
|
||||||
|
|
||||||
Only these legacy class aliases are recognized:
|
|
||||||
|
|
||||||
| Requested class | Canonical class |
|
|
||||||
| ---------------------- | --------------- |
|
|
||||||
| `implementer` | `code` |
|
|
||||||
| `reviewer` | `review` |
|
|
||||||
| `operator-interaction` | `interaction` |
|
|
||||||
|
|
||||||
No other alias is inferred. In particular, `worker`, `analyst`, and `canary` are custom classes only
|
|
||||||
when an operator supplies a readable contract for that exact class. Tess and Ultron are instance
|
|
||||||
names, not classes. `agents[].alias` is display-only and cannot grant authority.
|
|
||||||
|
|
||||||
Canonicalization happens before role lookup. For example, requesting `implementer` resolves
|
|
||||||
`code.md`; a separate `roles.local/implementer.md` cannot redefine the legacy alias. A canonical
|
|
||||||
`roles.local/code.md` still overrides the baseline `roles/code.md` contract.
|
|
||||||
|
|
||||||
## Protected authority
|
|
||||||
|
|
||||||
Protected authority is immutable metadata derived only from canonical class. Role prose, instance
|
|
||||||
name, display alias, tool policy, runtime, and custom role files cannot grant it.
|
|
||||||
|
|
||||||
| Canonical class | Granted authority | Explicit limits |
|
|
||||||
| ----------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
||||||
| `merge-gate` | Sole approve-to-land and merge authority | No authority is inferred by similarly named custom roles or policies. |
|
|
||||||
| `validator` | May issue a validation certificate | Cannot approve-to-land or merge. |
|
|
||||||
| `orchestrator` | May orchestrate, manage topology, and issue leases | Cannot approve-to-land or merge. |
|
|
||||||
| `team-leader` | May use orchestrator-leased capacity | Cannot issue leases or mutate roster, configuration, credentials, or merge state. |
|
|
||||||
| `interaction` | Request and status surface | Cannot orchestrate, issue leases, mutate roster/configuration, or merge. |
|
|
||||||
| all other classes | No protected authority implicitly | Custom contracts do not acquire protected powers from prose. |
|
|
||||||
|
|
||||||
Roster-v2 semantic validation requires a protected class and its canonical tool policy to match. It
|
|
||||||
also rejects an unprotected class paired with a protected tool policy. The legacy tool-policy name
|
|
||||||
`operator-interaction` canonicalizes to `interaction`.
|
|
||||||
|
|
||||||
This mapping describes authority metadata only. Lease issuance, validation-certificate storage or
|
|
||||||
workflow, lifecycle reconciliation, credentials, roster mutation, and merge execution are outside
|
|
||||||
this resolver contract.
|
|
||||||
@@ -81,35 +81,6 @@ agents:
|
|||||||
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
||||||
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
||||||
|
|
||||||
## Semantic handoff
|
|
||||||
|
|
||||||
`parseRosterV2` and `normalizeRosterV2` remain synchronous and structural. After structural success,
|
|
||||||
call the asynchronous `validateRosterV2Semantics` handoff before using persona identity or authority.
|
|
||||||
That validator batches the baseline `fleet/roles/` and operator `fleet/roles.local/` scans, then
|
|
||||||
delegates every agent to the shared persona resolver.
|
|
||||||
|
|
||||||
Semantic validation:
|
|
||||||
|
|
||||||
- requires the winning role contract to be readable and non-empty; `LIBRARY.md` membership alone does
|
|
||||||
not resolve a class;
|
|
||||||
- retains `requestedClass` separately from `canonicalClass` in typed output;
|
|
||||||
- canonicalizes only `implementer` to `code`, `reviewer` to `review`, and
|
|
||||||
`operator-interaction` to `interaction`;
|
|
||||||
- canonicalizes `tool_policy` with the same exact alias table;
|
|
||||||
- rejects protected class/tool-policy mismatches in either direction, while accepting
|
|
||||||
`class: operator-interaction` with `tool_policy: operator-interaction` as canonical
|
|
||||||
`interaction`;
|
|
||||||
- derives immutable protected authority only from canonical class; and
|
|
||||||
- accepts custom baseline or `roles.local` classes without granting protected authority.
|
|
||||||
|
|
||||||
`agents[].alias` remains display-only. Tess and Ultron are instance names, never semantic classes.
|
|
||||||
Canonicalization happens before role-layer lookup, so a legacy-named override cannot redefine an
|
|
||||||
alias as separate authority. See [Role Classes and Authority](./role-classes.md) and
|
|
||||||
[Customize Fleet Roles](../how-to/customize-roles.md).
|
|
||||||
|
|
||||||
This handoff performs no filesystem, systemd, tmux, roster, credential, lease, certificate, or
|
|
||||||
lifecycle mutation.
|
|
||||||
|
|
||||||
## Fail-closed boundary
|
## Fail-closed boundary
|
||||||
|
|
||||||
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,
|
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
# FCM-M1-002 — Shared role resolution
|
|
||||||
|
|
||||||
- **Task:** `FCM-M1-002`
|
|
||||||
- **Issue:** `mosaicstack/stack#758`
|
|
||||||
- **Branch:** `feat/758-shared-role-resolution`
|
|
||||||
- **Starting head:** `32e75c67b094de443d37fe7d5ff8d25cdfc8b39d`
|
|
||||||
- **Role:** implementation worker; independent review and merge remain outside this worker
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Reuse the existing baseline-plus-`roles.local` persona resolver as the sole class authority for roster-v2 semantics, profile validation, provisioning, and launch/persona resolution. Add exact approved alias canonicalization, fail-closed semantic validation, immutable canonical-class authority contracts, required baseline roles, and operator documentation without implementing lifecycle, mutation, credentials, certificate workflow, or later FCM cards.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
- Soft budget: **25K tokens**.
|
|
||||||
- Strategy: inspect once, implement in small TDD units, run focused suites before the full package gate, and avoid unrelated refactors or M1-003/M2/M4 scope.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Map the existing persona resolver, roster-v2 compiler, profile/provision consumers, launch resolution, role library, and focused tests.
|
|
||||||
2. Write denial/invariant tests first for aliases, canonicalization-before-override, unreadable roles, authority boundaries, policy mismatch, canonical provision output, and resolver parity.
|
|
||||||
3. Run the focused suites and record the expected red evidence.
|
|
||||||
4. Implement one shared canonical resolution and authority contract in/through `fleet-personas.ts`; delegate roster semantic validation and profile/provision paths to it.
|
|
||||||
5. Add baseline `validator`, `team-leader`, and `interaction` role contracts plus `LIBRARY.md` entries while retaining `operator-interaction` compatibility.
|
|
||||||
6. Add the required role reference, alias migration, customization guide, and roster-v2 semantic handoff documentation.
|
|
||||||
7. Run focused tests, the full `@mosaicstack/mosaic` suite, typecheck, lint, Prettier, `git diff --check`, situational verification, independent code/security review, and remediation.
|
|
||||||
8. Commit with the required co-author trailer, run the CI queue guard, push the existing branch, and create/update exactly one PR to `main` with `Refs #758`.
|
|
||||||
|
|
||||||
## TDD evidence
|
|
||||||
|
|
||||||
### Red
|
|
||||||
|
|
||||||
After installing worktree-local dependencies and building `@mosaicstack/db`, the pre-implementation
|
|
||||||
focused run collected the intended tests and failed as expected:
|
|
||||||
|
|
||||||
```text
|
|
||||||
2 test files failed; 32 tests failed; 32 tests passed
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected failures named the missing `canonicalizeRoleClass`,
|
|
||||||
`authorityForCanonicalClass`, and `validateRosterV2Semantics` APIs, absent requested/canonical typed
|
|
||||||
output, and unresolved required canonical role contracts. An earlier run that failed before test
|
|
||||||
collection on an unresolved `yaml` dependency was treated as environment setup, not TDD evidence.
|
|
||||||
|
|
||||||
### Green
|
|
||||||
|
|
||||||
Focused role-resolution and affected service fixtures:
|
|
||||||
|
|
||||||
```text
|
|
||||||
6 test files passed; 109 tests passed
|
|
||||||
```
|
|
||||||
|
|
||||||
The focused set covers personas, profiles, provision, launch persona contract, roster-v2 semantics,
|
|
||||||
and the operator-interaction service fixture. The final profile tests also cover readable lead/floor
|
|
||||||
compatibility and canonical collision denial.
|
|
||||||
|
|
||||||
## Tests and gates
|
|
||||||
|
|
||||||
- Focused suites: pass, **6 files / 109 tests**.
|
|
||||||
- Full `@mosaicstack/mosaic` suite: pass, **50 files / 713 tests**. Workspace package build outputs
|
|
||||||
were prepared first because a clean worktree has no dependency `dist` entries.
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic typecheck`: pass.
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic lint`: pass.
|
|
||||||
- Prettier check over every changed file: pass.
|
|
||||||
- `git diff --check`: pass.
|
|
||||||
- Runtime/file-boundary evidence: real role library, profile/provision filesystem integration,
|
|
||||||
launch-time synchronous contract injection, v1 roster parser round-trip, roster-v2 semantic
|
|
||||||
filesystem checks, and operator-interaction service fixtures all pass without live mutation.
|
|
||||||
- Independent code review: **APPROVE**, no blocking or non-blocking findings; reviewed complete
|
|
||||||
tracked/untracked delta including the canonical collision guard. Residual: roster-v2 semantic
|
|
||||||
validation is an explicit async handoff with production caller wiring owned by later work.
|
|
||||||
- Independent security review: **APPROVE**, no verified authority/security findings on the final
|
|
||||||
delta.
|
|
||||||
|
|
||||||
### Post-PR fail-closed remediation
|
|
||||||
|
|
||||||
Independent rereview found resolver fail-open edges that the original green PR head did not cover. The
|
|
||||||
remediation remained uncommitted until every finding was reproduced red-first and the same reviewer
|
|
||||||
approved the complete two-file delta.
|
|
||||||
|
|
||||||
Final regression evidence:
|
|
||||||
|
|
||||||
```text
|
|
||||||
persona resolver: 47/47
|
|
||||||
focused affected suites: 6 files / 138 tests
|
|
||||||
root-container resolver suites: 86/86
|
|
||||||
full canonical run: 42/42 Turbo tasks; Mosaic 50 files / 733 tests
|
|
||||||
```
|
|
||||||
|
|
||||||
DB migration, typecheck, lint, Prettier, and `git diff --check` also passed. Coverage now proves:
|
|
||||||
|
|
||||||
- unreadable, unscannable, direct-dangling, ancestor-dangling, and literal `..` traversal override paths
|
|
||||||
fail closed across async, sync, listing, and status APIs;
|
|
||||||
- genuinely missing override directories still permit baseline fallback;
|
|
||||||
- cached missing scans are revalidated before fallback;
|
|
||||||
- marker-defined identity and domain metadata are revalidated on the second read;
|
|
||||||
- `LIBRARY.md` rows and incidental later markers cannot define, shadow, or advertise personas.
|
|
||||||
|
|
||||||
Exact-head pipeline `1819` passed for rebased head `4d990eee…`, but the independent reviewer-of-record
|
|
||||||
returned **REQUEST CHANGES** after reproducing three additional edge failures: a canonical filename could
|
|
||||||
inherit protected authority despite a conflicting explicit first marker, cached `scanned` absence could
|
|
||||||
miss an override created before baseline fallback, and inherited plain-object names such as `constructor`
|
|
||||||
could corrupt alias/authority lookup. Merge remained held.
|
|
||||||
|
|
||||||
Each failure was reproduced red-first in the persona suite (4 failing assertions), then remediated without
|
|
||||||
expanding card scope. Explicit first markers now own identity and filename fallback applies only to
|
|
||||||
markerless contracts; every second read rejects a newly introduced conflicting marker regardless of
|
|
||||||
cached classification; cached async resolution re-scans the override layer immediately before every
|
|
||||||
baseline fallback; alias and authority registries require own-property matches. Current uncommitted
|
|
||||||
evidence is persona **52/52**, focused affected suites **6 files / 143 tests**, and full Mosaic package
|
|
||||||
**50 files / 738 tests**, plus typecheck, lint, Prettier, and `git diff --check`. Independent
|
|
||||||
finding-specific rereview **APPROVED** the complete uncommitted three-file remediation after direct
|
|
||||||
adversarial reproduction of all three findings and the follow-up markerless TOCTOU. All post-commit
|
|
||||||
exact-head gates remain required.
|
|
||||||
|
|
||||||
## Risks and boundaries
|
|
||||||
|
|
||||||
- **Security-sensitive authority:** authority must derive only from canonical class, never role prose, aliases, display names, or tool-policy text.
|
|
||||||
- **Resolver divergence:** no second regex, registry, scanner, or prose parser may be introduced.
|
|
||||||
- **Alias capture:** aliases must canonicalize before baseline/`roles.local` lookup so local files cannot redefine legacy aliases as separate authority.
|
|
||||||
- **Readable persona requirement:** semantic success requires a resolved readable persona, not class-set membership.
|
|
||||||
- **Scope control:** no roster mutation, lifecycle, lease issuance, certificate workflow/storage, credentials, remote reconciliation, provision-v2 conversion, or shipped-example disposition execution.
|
|
||||||
- **Coordination:** `docs/TASKS.md` is read-only and remains orchestrator-owned.
|
|
||||||
|
|
||||||
## Acceptance-evidence mapping
|
|
||||||
|
|
||||||
| Requirement / criterion | Verification evidence |
|
|
||||||
| --- | --- |
|
|
||||||
| `FCM-REQ-02` shared semantic resolver | Async/sync resolver parity; roster-v2 delegates batched scans and resolution; profiles/provision and launch reuse `fleet-personas.ts`; no second scanner or class-marker regex added. |
|
|
||||||
| `FCM-REQ-07` canonical classes and authority boundaries | Exact alias and non-alias tests; immutable authority invariant tests; all required canonical contracts resolve through the real role library. |
|
|
||||||
| `AC-FCM-01` structural + semantic roster validation | Synchronous parser/normalizer tests remain intact; async semantic tests cover aliases, custom roles, unreadable/unresolved roles, `LIBRARY`-only rejection, and bidirectional protected policy mismatch. |
|
|
||||||
| `AC-FCM-07` protected authority invariants | Denial tests prove merge-gate-only merge, validator certificate-only, orchestrator/team-leader/interaction limits, no implicit custom-role authority, and canonical tool-policy matching. |
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- `docs/fleet/reference/role-classes.md`
|
|
||||||
- `docs/fleet/migration/legacy-class-aliases.md`
|
|
||||||
- `docs/fleet/how-to/customize-roles.md`
|
|
||||||
- `docs/fleet/reference/roster-v2-fields.md` semantic handoff
|
|
||||||
- Baseline role contracts and `LIBRARY.md` rows for `validator`, `team-leader`, and `interaction`
|
|
||||||
|
|
||||||
## Residual risks
|
|
||||||
|
|
||||||
- Provisioning remains intentionally v1 and does not emit `reports_to`; canonical topology is retained
|
|
||||||
in its typed seat/summary path only, matching the existing v1 parser boundary.
|
|
||||||
- Alias support remains for compatibility; new configuration should emit canonical identities.
|
|
||||||
- This card defines authority metadata and validation only. Enforcement workflows for leases,
|
|
||||||
certificates, lifecycle, and mutation remain owned by later FCM cards.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# FCM-M1-003 — Executable example/profile/service-preset dispositions
|
|
||||||
|
|
||||||
- **Task / issue:** FCM-M1-003 / #758
|
|
||||||
- **Branch / base:** `test/758-example-profile-dispositions` from `origin/main` `a5e8e554012f27898e035d2882a8e47e1a02fe97`
|
|
||||||
- **Objective:** Make every artifact in the M0 legacy disposition inventory executable evidence: it must validate canonically, be explicitly retained as a v1 fixture, or be retired with a replacement/deprecation link.
|
|
||||||
- **Scope:** Validation and explicit version/retirement metadata only for shipped examples, profiles, and the operator-interaction service preset. Reuse the central resolver and existing v2 roster compiler.
|
|
||||||
- **Out of scope:** Generated environment boundaries, CRUD, reconciliation/apply, migration, live fleet mutation, and `docs/TASKS.md`.
|
|
||||||
- **Budget:** 20K card allocation; use focused package tests before full package validation.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Inventory exact shipped artifacts and existing compiler/resolver/profile tests.
|
|
||||||
2. Add failing behavior tests covering all listed artifacts and their documented disposition.
|
|
||||||
3. Implement minimal declarative fixture/disposition validation; do not add a role/class resolver.
|
|
||||||
4. Run focused and package quality gates; obtain independent code and security review.
|
|
||||||
5. Commit, queue-guard, push, open one `main` PR with `Refs #758`.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Intake complete: verified no branch, worktree, or open PR for this card before creating this isolated worktree.
|
|
||||||
- Requirements read: FCM PRD, FCM-M1-003 task row, M0 disposition inventory, delivery/QA/documentation guides.
|
|
||||||
- TDD: RED recorded with `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` failing because the new module did not exist; GREEN recorded after the minimal guard implementation. The focused suite now has 4 passing tests, including undeclared-artifact and missing-explicit-v1-version denials.
|
|
||||||
- Independent review: initial code review found the service policy path was hardcoded; remediation iterates declared `canonical-service-policy` artifacts. Exact-head code review approved and exact-head security review found no issues.
|
|
||||||
|
|
||||||
## Risks / decisions
|
|
||||||
|
|
||||||
- The M0 inventory permits unresolved legacy roles only when explicitly v1-versioned or retired. Do not infer aliases beyond the three approved by FCM-M1-002.
|
|
||||||
- `docs/TASKS.md` is orchestrator-owned and will not be edited.
|
|
||||||
|
|
||||||
## Verification evidence
|
|
||||||
|
|
||||||
- Focused TDD guard: `pnpm --filter @mosaicstack/mosaic test -- example-profile-dispositions.spec.ts` — PASS (4 tests).
|
|
||||||
- Full package suite: `pnpm --filter @mosaicstack/mosaic test` — PASS (51 files, 742 tests).
|
|
||||||
- Static gates: `pnpm --filter @mosaicstack/mosaic typecheck`, `pnpm --filter @mosaicstack/mosaic lint`, and `pnpm format:check` — PASS.
|
|
||||||
- Diff gate: `git diff --check` — PASS.
|
|
||||||
- Exact-head reviews: `codex-code-review.sh --uncommitted` — APPROVE; `codex-security-review.sh --uncommitted` — no findings.
|
|
||||||
- Delivery: committed as `9a9ad1a`, pushed after `ci-queue-wait.sh --purpose push`, and opened PR [#770](https://git.mosaicstack.dev/mosaicstack/stack/pulls/770) to `main` with `Refs #758`. `pr-ci-wait.sh -n 770` reported terminal-green Woodpecker pipeline [#1823](https://ci.mosaicstack.dev/repos/47/pipeline/1823/1).
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# FCM-M2-002 — Generation-Guarded Fleet Agent CRUD
|
|
||||||
|
|
||||||
- **Task / issue:** FCM-M2-002 / #758
|
|
||||||
- **Branch / base:** `feat/758-fleet-agent-crud` from `origin/main` `191efaefeb5c0c6bb218c1292d12ce8e73ace12b`
|
|
||||||
- **Budget:** 30K card allocation; no deployment or live-fleet actions.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Provide local roster-owned create, get, update, and delete mutations with a generation precondition, deterministic dry-run plan, complete-state structural/semantic/projection validation before writes, atomic roster persistence, and redacted recovery output on a late projection failure.
|
|
||||||
|
|
||||||
## Scope and exclusions
|
|
||||||
|
|
||||||
- Roster v2 is the sole desired-state authority. Reuse `parseRosterV2`, `renderRosterV2Yaml`, `validateRosterV2Semantics`, and the generated-environment boundary.
|
|
||||||
- Fresh create defaults to `enabled: true` and `desired_state: stopped`; this card never starts a runtime.
|
|
||||||
- Excluded: reconcile/apply; lifecycle/session/systemd/tmux actions; migration/canary; remote/connector/gateway mutation; arbitrary commands/channels/secrets; generated files as authority; `docs/TASKS.md` and orchestration ledger changes.
|
|
||||||
|
|
||||||
## Red-first plan
|
|
||||||
|
|
||||||
1. Add failing tests for dry-run non-mutation, stale generation, concurrent writer locking, stopped default create, equivalent idempotency, complete proposed-state semantic/boundary validation, atomic roster write, and injected late projection failure with redacted recovery details.
|
|
||||||
2. Implement only a roster-v2 CRUD service and file adapter; no legacy `fleet add/remove` behavior expansion.
|
|
||||||
3. Add operator/reference docs for JSON outcomes, generation retries, recovery, and no-runtime-action boundary.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Preflight: clean exact base and no duplicate PR confirmed.
|
|
||||||
- Intake read: FCM PRD/AC-FCM-03, task row, launch and generated-env boundaries, roster v2/resolver contracts, legacy fleet command behavior, delivery/QA/TypeScript/security/documentation guidance.
|
|
||||||
- TDD: RED observed for the missing CRUD module. GREEN: focused suite passes 7 tests covering stopped-default create, stale generation, idempotency, dry-run non-mutation, concurrent lock denial, exact stale/absent generated-projection delete cleanup, and redacted late-projection recovery.
|
|
||||||
- REVIEW-1 remediation: RED observed for absent CLI create/get/update/delete/plan wiring. Added roster-v2-only JSON commands, including safe `get`, read-only planning/dry-run, explicit persisted-start recording (never runtime start), stable handled error codes, and focused CLI coverage.
|
|
||||||
- REVIEW-2 remediation: RED observed for missing direct fleet-control-plane registration and ambiguous partial late-I/O result. The public surface is now direct `mosaic fleet {create,get,update,delete,plan}` (not root gateway `mosaic agent`); a late filesystem projection failure returns non-zero redacted JSON with `authoritativeRoster: committed` and `projections: incomplete`, proving no rollback/no-op claim.
|
|
||||||
- REVIEW-3 remediation: RED observed for unnamed update/delete plans and deleted-agent quarantine conflict. `plan <operation> [name]` now requires target names only for update/delete; delete validates/removes only exact generated state while retaining local/legacy/quarantine/unrelated files. Actual CLI/filesystem tests cover create/update/delete plans, plan validation/non-mutation, retained artifacts/dry-run bytes, unsafe permission/symlink rejection, and post-roster delete recovery. Added the required operator how-to.
|
|
||||||
- REVIEW-4 remediation: RED observed that actual Commander create accepted and silently dropped disallowed `command`, `channel`, and `secretRef` keys. The `--agent` object and nested `launch` now use strict own-property allowlists; non-plain/prototype-sensitive shapes and unknown keys fail `invalid-request` before resolver, roster, or projection mutation. Actual Commander plan/create/update tests cover top-level command/channel/secretRef/constructor/prototype/`__proto__` shapes and nested launch unknown fields, byte-identical retained artifacts, non-zero exit, and diagnostics that never echo rejected values. Docs state the same boundary.
|
|
||||||
|
|
||||||
## Risks / assumptions
|
|
||||||
|
|
||||||
- **ASSUMPTION:** M2 mutations operate exclusively on the existing v2 roster contract because generation/lifecycle fields are v2-only; legacy v1 add/remove commands remain unchanged compatibility paths.
|
|
||||||
- A multi-file roster/projection write cannot be one filesystem rename. The roster is authoritative; a post-roster projection failure returns a redacted recovery plan naming only safe paths/actions, never environment values.
|
|
||||||
|
|
||||||
## Verification evidence
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test -- fleet-agent-crud-command.spec.ts fleet-agent-crud.spec.ts generated-env-boundary.spec.ts` — PASS (48 tests after REVIEW-4 remediation).
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test` — PASS (54 files, 794 tests after REVIEW-4 remediation).
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic lint` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic typecheck` — PASS.
|
|
||||||
- `pnpm format:check` and `git diff --check` — PASS.
|
|
||||||
- Root `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `git diff --check` — PASS after REVIEW-4 remediation; full package test is 54 files / 794 tests.
|
|
||||||
- REVIEW-4 remediation focused checks are green; fresh full-delta independent review is required before author-green. No commit, push, or PR opened.
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# FCM-M2-001 — Generated Environment Boundary
|
|
||||||
|
|
||||||
- **Issue/card:** #758 / FCM-M2-001
|
|
||||||
- **Branch/base:** `feat/758-generated-env-boundary` from `origin/main` `e9c4aa3e8b3780719cd5a43c0ef3f37fc70de666`
|
|
||||||
- **Budget assumption:** 30K-card budget; implement only the deterministic generated/local environment boundary and its launch-chain/docs/tests.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Replace the generic fleet agent `.env` authority/merge path with a deterministic roster-derived `<agent>.env.generated` projection and strict, data-only `<agent>.env.local`. The roster remains the desired-state authority. Reject bad input before the launcher creates a tmux session; never print sensitive or privileged-command values.
|
|
||||||
|
|
||||||
## Scope and non-goals
|
|
||||||
|
|
||||||
- In scope: deterministic render/write, strict generated/local parse rules, legacy `.env` disposition/quarantine, systemd/launcher boundary, permission/path checks, focused fail-closed tests, operator/reference documentation, USC interface evidence.
|
|
||||||
- Excluded: roster CRUD/mutation, v2 roster schema changes, lifecycle/reconcile/apply behavior, migration/canary rollout, connectors, remote surfaces, live-fleet actions, and M2-002.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add red tests for generated-key shadowing, malformed/duplicate/unknown/command/sensitive input, no-value diagnostics, deterministic/idempotent projection, secure file modes, and legacy disposition.
|
|
||||||
2. Implement a pure strict environment contract plus atomic projection/quarantine helper.
|
|
||||||
3. Replace the generic `.env` writer/merge path and systemd reference with `.env.generated` + `.env.local` ownership.
|
|
||||||
4. Make the shell launcher parse the files without `source`/`eval`, reject unsafe input before tmux creation, and construct only the roster-derived runtime command.
|
|
||||||
5. Add operator/reference documentation with the requested USC M1 interface evidence and M2–M4 gate statement.
|
|
||||||
6. Run focused/package/root gates and audit the USC interface packet. Per continuation scope, stop before review, commit, push, PR, or live mutation.
|
|
||||||
|
|
||||||
## Initial evidence
|
|
||||||
|
|
||||||
- No existing owner: target worktree path absent; no target local/remote branch; `pr-list.sh -s open` returned no open PRs.
|
|
||||||
- M1 compiler/API/docs and executable disposition evidence are present at the assigned base.
|
|
||||||
- Existing launch chain writes `fleet/agents/<agent>.env`, preserves arbitrary legacy lines via `mergeAgentEnv`, sources `MOSAIC_AGENT_COMMAND`, and executes it through `bash -c`; all are M2 remediation targets.
|
|
||||||
- `~/.config/mosaic/guides/SECURITY.md` is absent. Read the available security-review role contract and the vault/secrets guide instead.
|
|
||||||
|
|
||||||
## Verification log
|
|
||||||
|
|
||||||
### Continuation (2026-07-14)
|
|
||||||
|
|
||||||
- Preserved the inherited 14-file delta; no reset, stash, rebase, roster mutation, lifecycle action,
|
|
||||||
live-fleet action, commit, push, or PR action was performed.
|
|
||||||
- Focused gates passed:
|
|
||||||
- `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` — 1 file, 10 tests passed.
|
|
||||||
- `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` — passed.
|
|
||||||
- `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — passed.
|
|
||||||
- `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` — 1 file, 192 tests passed.
|
|
||||||
- Package gates passed before final documentation/format follow-up:
|
|
||||||
- `pnpm --dir packages/mosaic typecheck` — passed.
|
|
||||||
- `pnpm --dir packages/mosaic lint` — passed.
|
|
||||||
- `pnpm --dir packages/mosaic test` — 52 files, 752 tests passed.
|
|
||||||
- `pnpm format:check` initially failed only for the new boundary reference and generated-boundary
|
|
||||||
TypeScript files; targeted Prettier normalization was applied. A final `pnpm format:check` passed.
|
|
||||||
- USC packet audit: the M1 structural compiler is `parseRosterV2` with roster `version: 2`; the
|
|
||||||
semantic resolver is `validateRosterV2Semantics`; disposition artifacts retain `version: 1` fixture
|
|
||||||
evidence. `docs/TASKS.md` records M1-001 done, M1-002 in-progress, and M1-003 not-started; no
|
|
||||||
product release version is claimed. The packet now distinguishes these statuses from checkout
|
|
||||||
artifact presence and states the M2 → M3 → M4 downstream gates.
|
|
||||||
|
|
||||||
## Review remediation (2026-07-14)
|
|
||||||
|
|
||||||
- **Blocker 1 red-first:** Added a launcher reproducer with a `0777` `fleet/agents` parent and a private generated file. Before implementation, `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` failed: `FAIL: generated file under a world-writable parent was accepted`. The failure occurred after the launch path reached fake tmux, proving the parent was not validated.
|
|
||||||
- **Blocker 2 red-first:** Added a `symlink()` projection-directory reproducer that preloads generated/local/quarantine/legacy target files and asserts no target mutation. Before implementation, `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` failed the new test because the existing writer followed the `agentEnvDir` symlink and parsed its target legacy input (`expected /unsafe-directory/i`, received `code=malformed-line`). The initial test-only missing `mkdir` import was corrected before recording this behavior failure.
|
|
||||||
- **Blocker 3 red-first:** Added fresh/stale/absent native-heartbeat regression coverage. Before implementation, an isolated fake-tmux launcher reproducer with a fresh `<agent>.hb.native` marker failed `FAIL: fresh native heartbeat was overwritten`; the existing sidecar immediately replaced native `status=busy`/`model` content.
|
|
||||||
- **Remediation result:** The launcher now rejects a group/world-accessible or symlinked `fleet/agents` parent before an environment read or tmux call. The projection writer uses `lstat` before chmod/write processing and rejects a symlinked directory without creating generated/local/quarantine files or deleting legacy input. The heartbeat sidecar defers to a fresh non-symlink native marker and falls back when stale/absent. Focused green evidence before independent review: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` and `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` (11 tests) passed.
|
|
||||||
- **Independent-review follow-up red-first:** Codex code review returned one blocker and security review one medium CWE-732 finding: the writer repaired an already `0777` directory with `chmod` before trusting its contents. Added a reproducer with a safe local file beneath an existing `0777` directory. Before the follow-up fix, `pnpm --dir packages/mosaic test -- src/fleet/generated-env-boundary.spec.ts` failed because the promise resolved and wrote `coder0.env.generated` instead of rejecting.
|
|
||||||
- **Independent-review remediation:** Existing directories now pass non-following private-directory validation before any read or chmod; only a directory created in this call is normalized to `0700`. The fleet-add test fixture now creates its simulated trusted `fleet/agents` boundary at `0700`; this corrects fixture setup to match the new required contract rather than weakening the rejection assertion. Focused reruns passed: generated-boundary 12 tests, launcher boundary suite, and fleet suite 192 tests.
|
|
||||||
- **Final verification before re-review:** Launcher + systemd suites passed; package suite passed (52 files, 754 tests); package lint/typecheck, root typecheck (42 tasks), format check, and diff check passed. The rerun code review still reports a tmux command-arity blocker, and the security rerun reports systemd `EnvironmentFile` pre-validation injection findings for both agent units. These were discovered after the specified three-remediation scope; no additional source changes were made. Independent review therefore remains `REQUEST CHANGES` despite the requested three fixes passing their behavioral suites.
|
|
||||||
|
|
||||||
## Systemd pre-validation remediation (2026-07-14)
|
|
||||||
|
|
||||||
- **Red-first:** Updated the fleet unit contract to reject any `EnvironmentFile=` projection preload, require a cleared bootstrap environment, and require a validated exact-stop path. Before implementation, `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` failed: `FAIL: agent units must not preload projections before strict parsing`.
|
|
||||||
- **Red-first parser/stop coverage:** Added interaction-wrapper and exact-stop cases to the launcher boundary suite. Before implementation, `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` failed: `FAIL: interaction did not use shared strict parser first`, because the interaction wrapper consumed inherited environment before projection validation.
|
|
||||||
- **Focused green:** Both unit templates now use `env -i` with fixed `HOME`, agent instance, and PATH; neither has `Environment=`/`EnvironmentFile=`. The interaction wrapper delegates to `start-agent-session.sh --interaction`, so strict generated/local parsing precedes pinned Pi profile checks. `--stop` reuses the strict generated parser before exact `=<agent>` socket/session termination. Passed: systemd unit suite, launcher boundary suite (including malformed interaction, pinned profile, and ambient-socket stop cases), and 210 focused TypeScript tests.
|
|
||||||
- **Final verification:** `pnpm --dir packages/mosaic test` passed (52 files, 754 tests); package lint/typecheck, root typecheck (42 tasks), format/diff, and shell syntax checks passed. Security review passed with no findings. Code review repeated the previously refuted tmux argv concern and a pre-existing Claude trust-lock suggestion; per the assigned narrow follow-up, no tmux or unrelated trust-path change was made.
|
|
||||||
|
|
||||||
## Risks and next review
|
|
||||||
|
|
||||||
- This card is uncommitted and unreleased. The canonical tracker still records its dependencies as
|
|
||||||
M1-002 in progress and M1-003 not started; this continuation does not reinterpret those task states.
|
|
||||||
- Final post-documentation checks passed: `pnpm --dir packages/mosaic typecheck`,
|
|
||||||
`pnpm --dir packages/mosaic lint`, `pnpm --dir packages/mosaic test` (52 files, 752 tests),
|
|
||||||
`pnpm typecheck` (42 Turbo tasks), and `pnpm format:check`.
|
|
||||||
- Obtain independent code and security review of the complete delta next. Do not run commit, push,
|
|
||||||
PR, or live-fleet commands in this continuation.
|
|
||||||
|
|
||||||
## Fresh-install directory remediation (2026-07-14)
|
|
||||||
|
|
||||||
- **Objective:** Remediate only the fresh-install path where `installFleet` created `fleet/agents`
|
|
||||||
with host-umask permissions before the boundary writer correctly rejected it.
|
|
||||||
- **Plan:** Add a real `fleet install --no-enable` integration reproducer; prove red; let the
|
|
||||||
existing boundary writer own directory creation; run focused and full gates. No commit, push,
|
|
||||||
PR, review disposition, or live-fleet action.
|
|
||||||
- **Red evidence:** Before the one-line remediation,
|
|
||||||
`pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` failed the new test with
|
|
||||||
`AgentEnvBoundaryError: code=unsafe-permissions` at `ensurePrivateProjectionDirectory`, after
|
|
||||||
`installFleet` pre-created the directory.
|
|
||||||
- **Change:** Removed only the recursive `mkdir(activePaths.agentEnvDir)` in `installFleet`.
|
|
||||||
`writeAgentEnvironmentProjection` remains the sole creator and retains its existing `lstat`,
|
|
||||||
private-directory, symlink, and existing-unsafe-directory fail-closed checks.
|
|
||||||
- **Focused green:** `pnpm --dir packages/mosaic test -- src/commands/fleet.spec.ts` — 193 tests
|
|
||||||
passed. The new integration executes a fresh `fleet install --no-enable`, asserts a real
|
|
||||||
non-symlink `0700` directory and a `0600` generated projection. Existing unsafe-directory
|
|
||||||
coverage remains in `generated-env-boundary.spec.ts` and asserts no chmod repair/no generated
|
|
||||||
file write.
|
|
||||||
- **Full gates green:** generated-boundary 12 tests; launcher and systemd suites; package
|
|
||||||
typecheck/lint and 52 files / 755 tests; root typecheck (42 tasks), lint, format, diff check,
|
|
||||||
and root test (42 tasks) all passed.
|
|
||||||
- **Independent review:** The complete inherited uncommitted delta still has Codex `REQUEST CHANGES`
|
|
||||||
findings outside this narrow fix (tmux command arity and Claude trust-lock regression), plus a
|
|
||||||
security-review medium finding on unvalidated writable ancestor directories. No out-of-scope
|
|
||||||
source changes were made.
|
|
||||||
- **Risk:** The writer's existing create-then-validate sequence is relied on for the creation
|
|
||||||
boundary; a concurrent substitution causes fail-closed validation rather than repair. The
|
|
||||||
review findings above remain residual risks for the complete card delta.
|
|
||||||
@@ -9,8 +9,7 @@ package, normally at:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
|
The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
|
||||||
default tmux server. The roster is the desired-state authority; generated environment files are
|
default tmux server.
|
||||||
rebuildable projections, never a second source of configuration.
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
@@ -32,17 +31,6 @@ The installed `tools/fleet/print-interaction-effective-policy.sh` prints only
|
|||||||
the resolved name, runtime, model, reasoning, and tool policy. It never reads
|
the resolved name, runtime, model, reasoning, and tool policy. It never reads
|
||||||
or prints credential variables.
|
or prints credential variables.
|
||||||
|
|
||||||
## Generated agent environment boundary
|
|
||||||
|
|
||||||
`mosaic fleet install` writes a private deterministic projection at
|
|
||||||
`~/.config/mosaic/fleet/agents/<agent>.env.generated`. It may relocate only approved local machine
|
|
||||||
data to `<agent>.env.local`; generated keys, arbitrary commands, secret-like keys, duplicate keys,
|
|
||||||
unknown keys, and unsafe permissions fail before a tmux session is created. Legacy `.env` input is
|
|
||||||
regenerated, relocated, or quarantined and is not a launch authority.
|
|
||||||
|
|
||||||
See [`docs/fleet/reference/generated-env-boundary.md`](../../../../docs/fleet/reference/generated-env-boundary.md)
|
|
||||||
for allowed local keys and the USC downstream interface evidence.
|
|
||||||
|
|
||||||
Initialize a roster:
|
Initialize a roster:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -12,22 +12,19 @@ on demand. Engineering personas have no explicit `domain:` marker (they are the
|
|||||||
implicit `engineering` domain); cross-domain personas carry a `domain:` key in
|
implicit `engineering` domain); cross-domain personas carry a `domain:` key in
|
||||||
their intro so tooling can group them.
|
their intro so tooling can group them.
|
||||||
|
|
||||||
> This file is an index, not an authority source. The fleet persona resolver reads
|
> This file is an index only — no code imports it. To add a persona, drop a new
|
||||||
> its rows for discovery compatibility, then requires a readable `*.md` contract;
|
> `*.md` next to the others (mirroring the existing structure) and add a row here.
|
||||||
> authority is derived from canonical class metadata in code, never from this prose.
|
|
||||||
|
|
||||||
## engineering
|
## engineering
|
||||||
|
|
||||||
| Persona | Purpose |
|
| Persona | Purpose |
|
||||||
| --------------- | ------------------------------------------------------------------------------ |
|
| --------------- | ------------------------------------------------------------------------------ |
|
||||||
| orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work |
|
| orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work |
|
||||||
| team-leader | Coordinates only orchestrator-leased capacity for one bounded project |
|
|
||||||
| board | Multi-lens deliberation panel; owns the mission's direction, not its execution |
|
| board | Multi-lens deliberation panel; owns the mission's direction, not its execution |
|
||||||
| planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG |
|
| planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG |
|
||||||
| decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges |
|
| decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges |
|
||||||
| code | Primary executor — one card, one branch, one PR to green CI |
|
| code | Primary executor — one card, one branch, one PR to green CI |
|
||||||
| review | Correctness reviewer — judges an open PR on correctness, scope, and coverage |
|
| review | Correctness reviewer — judges an open PR on correctness, scope, and coverage |
|
||||||
| validator | Independent final evidence certificate; never approves-to-land or merges |
|
|
||||||
| security-review | Second line of review — secrets, auth, and forbidden-path safety |
|
| security-review | Second line of review — secrets, auth, and forbidden-path safety |
|
||||||
| site-tester | Runtime verifier — runs the change and checks behavior vs. acceptance criteria |
|
| site-tester | Runtime verifier — runs the change and checks behavior vs. acceptance criteria |
|
||||||
| documentation | Prose maintainer — keeps human-facing docs and projections in sync |
|
| documentation | Prose maintainer — keeps human-facing docs and projections in sync |
|
||||||
@@ -36,7 +33,6 @@ their intro so tooling can group them.
|
|||||||
| operator | Escalation and control surface — owns exceptions and the fleet pause switch |
|
| operator | Escalation and control surface — owns exceptions and the fleet pause switch |
|
||||||
| session-review | Post-task retrospective — turns finished work into improvement signals |
|
| session-review | Post-task retrospective — turns finished work into improvement signals |
|
||||||
| enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness |
|
| enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness |
|
||||||
| interaction | Operator request/status surface; routes orchestration and merge decisions |
|
|
||||||
|
|
||||||
## executive
|
## executive
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# Interaction — fleet role definition
|
|
||||||
|
|
||||||
The **interaction** role (`class: interaction`) is the operator-facing request and status surface for Mosaic.
|
|
||||||
|
|
||||||
## Mandate
|
|
||||||
|
|
||||||
1. Receive operator requests and present observable fleet or runtime status.
|
|
||||||
2. Route orchestration requests to the orchestrator and merge decisions to the merge-gate.
|
|
||||||
3. Report supported actions and their outcomes without claiming another role's authority.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
- Request/status only; it does not orchestrate, issue leases, approve-to-land, or merge.
|
|
||||||
- It does not mutate roster configuration, role authority, or credentials.
|
|
||||||
- A configured instance name such as Tess is display data, never a class or authority source.
|
|
||||||
- `operator-interaction` remains a compatibility alias for this canonical class.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Team leader — fleet role definition
|
|
||||||
|
|
||||||
The **team-leader** (`class: team-leader`) coordinates a bounded project team using only capacity granted by an orchestrator-issued lease.
|
|
||||||
|
|
||||||
## Mandate
|
|
||||||
|
|
||||||
1. Direct the leased coder, reviewer, and validator capacity for the assigned project scope.
|
|
||||||
2. Track delivery status and return results or blockers to the orchestrator.
|
|
||||||
3. Stop using capacity when the lease or assignment ends.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
- Leased capacity only; this role does not issue or expand its own lease.
|
|
||||||
- It cannot change fleet roster membership, role authority, fleet configuration, or credentials.
|
|
||||||
- It cannot approve-to-land or merge.
|
|
||||||
- It does not displace the orchestrator's topology and lease authority.
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Validator — fleet role definition
|
|
||||||
|
|
||||||
The **validator** (`class: validator`) is the independent final evidence seat. It examines the accepted requirements, test evidence, review record, and candidate head and may issue a validation certificate for that exact evidence set.
|
|
||||||
|
|
||||||
## Mandate
|
|
||||||
|
|
||||||
1. Validate acceptance evidence independently from the implementation author.
|
|
||||||
2. Issue or withhold a final validation certificate for the reviewed candidate.
|
|
||||||
3. Report missing, stale, or contradictory evidence without altering it.
|
|
||||||
|
|
||||||
## Boundaries
|
|
||||||
|
|
||||||
- **Certificate only:** the validator does not approve-to-land or merge.
|
|
||||||
- It does not replace correctness or security review.
|
|
||||||
- It does not write product code, mutate the roster, issue leases, or access credentials.
|
|
||||||
- A configured instance name such as Ultron is display data, never a class or authority source.
|
|
||||||
@@ -24,56 +24,45 @@ The agent template calls:
|
|||||||
|
|
||||||
which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`.
|
which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`.
|
||||||
|
|
||||||
## Generated environment and local data
|
## Local customization
|
||||||
|
|
||||||
The roster-derived projection is written outside the package at:
|
Per-agent overrides live outside the package in:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/.config/mosaic/fleet/agents/<agent>.env.generated
|
~/.config/mosaic/fleet/agents/<agent>.env
|
||||||
```
|
```
|
||||||
|
|
||||||
Systemd does not read either environment file. It starts the launcher with a fixed cleared bootstrap
|
Example:
|
||||||
environment; before it creates, queries, or stops an exact agent tmux session, `start-agent-session.sh`
|
|
||||||
strictly parses the generated projection and the optional local data file:
|
|
||||||
|
|
||||||
```text
|
```dotenv
|
||||||
~/.config/mosaic/fleet/agents/<agent>.env.local
|
MOSAIC_TMUX_SOCKET=mosaic-fleet
|
||||||
|
MOSAIC_AGENT_RUNTIME=claude
|
||||||
|
MOSAIC_AGENT_WORKDIR=$HOME/src/your-project
|
||||||
|
# Optional escape hatch for PoC/canary agents:
|
||||||
|
# MOSAIC_AGENT_COMMAND=mosaic yolo claude
|
||||||
```
|
```
|
||||||
|
|
||||||
The local file may contain only safe machine-specific data (`MOSAIC_RUNTIME_BIN`, heartbeat paths or
|
|
||||||
interval, and Claude configuration paths). It cannot override roster-derived keys, carry a command,
|
|
||||||
or contain secret-like/unknown keys. Both files must be private regular files. Do not hand-edit the
|
|
||||||
generated projection; update the roster and regenerate it instead. A legacy `<agent>.env` is
|
|
||||||
consumed only for regeneration, strict relocation, or private quarantine and is never launch input.
|
|
||||||
|
|
||||||
See `docs/fleet/reference/generated-env-boundary.md` for the full contract.
|
|
||||||
|
|
||||||
## Manual canary sequence
|
## Manual canary sequence
|
||||||
|
|
||||||
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
|
||||||
edit a generated projection. `mosaic fleet install` validates the roster, installs the units and
|
|
||||||
helpers, and writes private roster-derived projections before any service is started.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
|
||||||
mosaic fleet init --profile minimal --write
|
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
|
||||||
mosaic fleet install
|
cp packages/mosaic/framework/tools/fleet/*.sh ~/.config/mosaic/tools/fleet/
|
||||||
|
chmod +x ~/.config/mosaic/tools/fleet/*.sh
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
mosaic fleet start canary-pi
|
systemctl --user start mosaic-tmux-holder.service
|
||||||
|
systemctl --user start mosaic-agent@canary.service
|
||||||
tmux -L mosaic-fleet ls
|
tmux -L mosaic-fleet ls
|
||||||
```
|
|
||||||
|
|
||||||
For an operator-interaction service, first put `<agent-name>` in the roster with the pinned Pi
|
# For an operator-interaction service, the roster/env identity selects the
|
||||||
runtime, model, reasoning, and `operator-interaction` tool policy. Re-run `mosaic fleet install` after
|
# generic unit instance; no service source is renamed for an instance.
|
||||||
that roster change so it writes `<agent-name>.env.generated`; ambient `MOSAIC_AGENT_*` values are not
|
|
||||||
launch authority. The generic unit instance uses that generated identity, and no service source is
|
|
||||||
renamed for an instance:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic fleet install
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
systemctl --user start mosaic-interaction-agent@<agent-name>.service
|
systemctl --user start mosaic-interaction-agent@<agent-name>.service
|
||||||
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh <agent-name>
|
MOSAIC_AGENT_NAME=<agent-name> \
|
||||||
|
MOSAIC_AGENT_RUNTIME=pi \
|
||||||
|
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol \
|
||||||
|
MOSAIC_AGENT_REASONING=high \
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY=operator-interaction \
|
||||||
|
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant
|
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ PartOf=mosaic-tmux-holder.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
# Remove loader and noninteractive-shell controls before ExecStart loads env.
|
|
||||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
# Never preload the projection. The launcher starts from a fixed minimal
|
# No default MOSAIC_TMUX_SOCKET: an absent roster socket means the literal
|
||||||
# environment and strictly validates generated/local data before tmux effects.
|
# default tmux socket (no -L). The per-agent .env sets it when the roster names
|
||||||
ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
# one; otherwise it stays unset and start-agent-session.sh uses the default socket.
|
||||||
ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i
|
Environment=MOSAIC_AGENT_NAME=%i
|
||||||
|
Environment=MOSAIC_AGENT_RUNTIME=pi
|
||||||
|
Environment=MOSAIC_AGENT_WORKDIR=%h
|
||||||
|
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
|
||||||
|
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
||||||
|
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ PartOf=mosaic-tmux-holder.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
# Remove loader and noninteractive-shell controls before ExecStart loads env.
|
|
||||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
# The interaction wrapper delegates to the shared strict parser before pinned
|
Environment=MOSAIC_AGENT_NAME=%i
|
||||||
# profile checks; no projection data reaches Bash through systemd.
|
EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env
|
||||||
ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i
|
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i
|
||||||
ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i
|
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -6,11 +6,10 @@ After=default.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
# The holder owns the tmux server, so clear loader, shell-control, and stale
|
Environment=MOSAIC_TMUX_SOCKET=mosaic-fleet
|
||||||
# manager/session variables before the server process starts.
|
Environment=MOSAIC_TMUX_HOLDER=_holder
|
||||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" "while true; do sleep 3600; done"'
|
||||||
ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh
|
ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
|
||||||
ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
|||||||
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
|
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
|
||||||
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
|
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
|
||||||
INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service"
|
INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service"
|
||||||
HOLDER_START="$SCRIPT_DIR/../../tools/fleet/start-tmux-holder.sh"
|
|
||||||
START_AGENT="$SCRIPT_DIR/../../tools/fleet/start-agent-session.sh"
|
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
echo "FAIL: $*" >&2
|
echo "FAIL: $*" >&2
|
||||||
@@ -16,40 +14,16 @@ fail() {
|
|||||||
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
|
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
|
||||||
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
|
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
|
||||||
[ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service"
|
[ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service"
|
||||||
[ -x "$HOLDER_START" ] || fail "missing executable start-tmux-holder.sh"
|
|
||||||
[ -x "$START_AGENT" ] || fail "missing executable start-agent-session.sh"
|
|
||||||
|
|
||||||
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
|
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
|
||||||
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
||||||
grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
|
grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
|
||||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$HOLDER" || \
|
|
||||||
fail "holder does not remove loader and shell-control variables"
|
|
||||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh' "$HOLDER" || \
|
|
||||||
fail "holder does not clear manager environment before starting tmux"
|
|
||||||
grep -qF 'ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c' "$HOLDER" || \
|
|
||||||
fail "holder stop does not clear manager environment"
|
|
||||||
if grep -qF -- '/bin/bash -lc' "$HOLDER"; then
|
|
||||||
fail "holder must not start tmux through a login shell"
|
|
||||||
fi
|
|
||||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||||
if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then
|
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
|
||||||
fail "agent units must not accept ambient or projection environment before strict parsing"
|
|
||||||
fi
|
|
||||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$AGENT" || \
|
|
||||||
fail "agent unit does not remove loader and shell-control variables"
|
|
||||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$INTERACTION" || \
|
|
||||||
fail "interaction unit does not remove loader and shell-control variables"
|
|
||||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$AGENT" || \
|
|
||||||
fail "agent unit does not clear bootstrap environment before strict parsing"
|
|
||||||
grep -qF 'start-agent-session.sh --stop %i' "$AGENT" || \
|
|
||||||
fail "agent stop does not use the validated exact-stop path"
|
|
||||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder"
|
grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder"
|
||||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$INTERACTION" || \
|
grep -qF 'EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env' "$INTERACTION" || fail "interaction service does not require per-agent config"
|
||||||
fail "interaction unit does not clear bootstrap environment before strict parsing"
|
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not validate before startup"
|
||||||
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not use shared strict parsing"
|
|
||||||
grep -qF 'start-agent-session.sh --stop %i' "$INTERACTION" || \
|
|
||||||
fail "interaction stop does not use the validated exact-stop path"
|
|
||||||
|
|
||||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||||
systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
||||||
@@ -58,102 +32,4 @@ if command -v systemd-analyze >/dev/null 2>&1; then
|
|||||||
}
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Real isolated socket regression: a preexisting server with an LD_PRELOAD
|
|
||||||
# constructor marker must fail closed, while a fresh named server is created.
|
|
||||||
if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then
|
|
||||||
TEST_ROOT=$(mktemp -d)
|
|
||||||
TEST_SOCKET="mosaic-holder-test-$$"
|
|
||||||
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
|
||||||
MARKER="$TEST_ROOT/loader-marker"
|
|
||||||
LIBRARY="$TEST_ROOT/marker.so"
|
|
||||||
HOLDER_HOME="$TEST_ROOT/holder-home"
|
|
||||||
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
|
||||||
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
|
||||||
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
|
||||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > \
|
|
||||||
"$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner"
|
|
||||||
chmod 600 "$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner"
|
|
||||||
cat > "$TEST_ROOT/marker.c" <<'EOF'
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
__attribute__((constructor)) static void mark_loader(void) {
|
|
||||||
const char *path = getenv("MOSAIC_LOADER_MARKER");
|
|
||||||
if (path != NULL) {
|
|
||||||
int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600);
|
|
||||||
if (fd >= 0) { write(fd, "loaded\\n", 7); close(fd); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
|
||||||
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
|
||||||
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60'
|
|
||||||
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
|
||||||
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
|
||||||
: > "$MARKER"
|
|
||||||
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
|
||||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START" \
|
|
||||||
>"$TEST_ROOT/holder.out" 2>&1; then
|
|
||||||
fail "holder adopted contaminated named server"
|
|
||||||
fi
|
|
||||||
grep -qF 'global environment does not match the owned-server contract' "$TEST_ROOT/holder.out" || \
|
|
||||||
fail "holder did not report contaminated server environment"
|
|
||||||
[ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \
|
|
||||||
fail "holder replaced a contaminated server instead of failing closed"
|
|
||||||
[ ! -s "$MARKER" ] || fail "holder execution triggered a contaminated loader"
|
|
||||||
|
|
||||||
# Agent validation must reject the same unmanaged server without cleaning its
|
|
||||||
# global environment or adding a managed session.
|
|
||||||
AGENT_HOME="$HOLDER_HOME/.config/mosaic"
|
|
||||||
AGENT_NAME=loader-safe
|
|
||||||
AGENT_WORKDIR="$AGENT_HOME/work"
|
|
||||||
AGENT_BIN="$TEST_ROOT/agent-bin"
|
|
||||||
mkdir -p "$AGENT_HOME/fleet/agents" "$AGENT_WORKDIR" "$AGENT_BIN"
|
|
||||||
chmod 700 "$AGENT_HOME/fleet/agents"
|
|
||||||
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
|
||||||
MOSAIC_AGENT_NAME=$AGENT_NAME
|
|
||||||
MOSAIC_AGENT_CLASS=code
|
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
|
||||||
MOSAIC_AGENT_MODEL=
|
|
||||||
MOSAIC_AGENT_REASONING=
|
|
||||||
MOSAIC_AGENT_TOOL_POLICY=code
|
|
||||||
MOSAIC_AGENT_WORKDIR=$AGENT_WORKDIR
|
|
||||||
MOSAIC_TMUX_SOCKET=$TEST_SOCKET
|
|
||||||
EOF
|
|
||||||
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$AGENT_BIN" > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local"
|
|
||||||
chmod 600 "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" \
|
|
||||||
"$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local"
|
|
||||||
cat > "$AGENT_BIN/mosaic" <<'EOF'
|
|
||||||
#!/bin/sh
|
|
||||||
sleep 30
|
|
||||||
EOF
|
|
||||||
chmod 700 "$AGENT_BIN/mosaic"
|
|
||||||
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
|
|
||||||
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
|
|
||||||
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
|
||||||
"$START_AGENT" "$AGENT_NAME" >"$TEST_ROOT/agent.out" 2>&1; then
|
|
||||||
fail "agent launcher adopted contaminated named server"
|
|
||||||
fi
|
|
||||||
[ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \
|
|
||||||
fail "agent launcher changed unmanaged server PID"
|
|
||||||
[ "$(tmux -L "$TEST_SOCKET" show-environment -g | sort)" = "$server_environment_before" ] || \
|
|
||||||
fail "agent launcher changed unmanaged global environment"
|
|
||||||
[ "$(tmux -L "$TEST_SOCKET" list-sessions | sort)" = "$server_sessions_before" ] || \
|
|
||||||
fail "agent launcher changed unmanaged sessions"
|
|
||||||
tmux -L "$TEST_SOCKET" kill-server
|
|
||||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
|
||||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
|
||||||
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
|
||||||
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then
|
|
||||||
fail "fresh holder retained LD_PRELOAD"
|
|
||||||
fi
|
|
||||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
|
||||||
"$START_AGENT" "$AGENT_NAME"
|
|
||||||
tmux -L "$TEST_SOCKET" has-session -t "=$AGENT_NAME:0.0" || \
|
|
||||||
fail "agent did not launch on a valid owned server"
|
|
||||||
tmux -L "$TEST_SOCKET" kill-server
|
|
||||||
trap - EXIT
|
|
||||||
rm -rf "$TEST_ROOT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "ok - fleet systemd unit templates"
|
echo "ok - fleet systemd unit templates"
|
||||||
|
|||||||
@@ -1,207 +1,39 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# FCM-M2-001 boundary: only a roster-derived .env.generated projection and a
|
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
|
||||||
# separately parsed data-only .env.local can influence launch. Never source an
|
# Absent socket ⇒ the LITERAL default tmux socket (no -L). The roster's
|
||||||
# environment file and never accept a command string from either file.
|
# socket_name is honored when set; absent never silently becomes mosaic-fleet
|
||||||
|
# (spawn stays consistent with the onboarding cheat-sheet + fleet ps observe).
|
||||||
|
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
|
||||||
|
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||||
|
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||||
|
MOSAIC_AGENT_REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||||
|
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||||
|
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
||||||
|
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
||||||
|
MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15}
|
||||||
|
|
||||||
MODE=launch
|
if [ -z "$AGENT_NAME" ]; then
|
||||||
case "${1:-}" in
|
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
|
||||||
--stop)
|
|
||||||
MODE=stop
|
|
||||||
AGENT_NAME=${2:-}
|
|
||||||
;;
|
|
||||||
--interaction)
|
|
||||||
MODE=interaction
|
|
||||||
AGENT_NAME=${2:-}
|
|
||||||
;;
|
|
||||||
*) AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} ;;
|
|
||||||
esac
|
|
||||||
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
echo "ERROR: $*" >&2
|
|
||||||
exit 64
|
exit 64
|
||||||
}
|
fi
|
||||||
|
|
||||||
hash_value() {
|
case "$MOSAIC_AGENT_REASONING" in
|
||||||
printf '%s' "$1" | sha256sum | awk '{print $1}'
|
''|low|medium|high) ;;
|
||||||
}
|
*)
|
||||||
|
echo "ERROR: MOSAIC_AGENT_REASONING must be low, medium, or high" >&2
|
||||||
fail_env() {
|
|
||||||
local code="$1"
|
|
||||||
local key="$2"
|
|
||||||
local value="$3"
|
|
||||||
echo "ERROR: agent environment rejected: code=${code} key=${key} sha256=$(hash_value "$value")" >&2
|
|
||||||
exit 64
|
exit 64
|
||||||
}
|
|
||||||
|
|
||||||
safe_agent_name() {
|
|
||||||
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
safe_policy_name() {
|
|
||||||
[[ "$1" =~ ^[a-z][a-z0-9-]*$ ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
safe_path() {
|
|
||||||
[[ "$1" == /* ]] || return 1
|
|
||||||
[[ "$1" != *".."* ]] || return 1
|
|
||||||
[[ ! "$1" =~ [[:space:]\"\'\`\$\\\;\|\&\<\>\(\)\{\}] ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_private_regular_file() {
|
|
||||||
local file="$1"
|
|
||||||
[ -f "$file" ] && [ ! -L "$file" ] || fail_env unsafe-file '(file)' "$file"
|
|
||||||
local mode
|
|
||||||
mode=$(stat -c '%a' -- "$file") || fail_env unsafe-file '(file)' "$file"
|
|
||||||
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(file)' "$file"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_managed_directory() {
|
|
||||||
local directory="$1"
|
|
||||||
[ -d "$directory" ] && [ ! -L "$directory" ] || fail_env unsafe-directory '(directory)' "$directory"
|
|
||||||
local mode
|
|
||||||
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
|
|
||||||
(( (8#$mode & 8#022) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_private_directory() {
|
|
||||||
local directory="$1"
|
|
||||||
assert_managed_directory "$directory"
|
|
||||||
local mode
|
|
||||||
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
|
|
||||||
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
|
|
||||||
}
|
|
||||||
|
|
||||||
[ -n "$AGENT_NAME" ] || fail "agent name argument or MOSAIC_AGENT_NAME is required"
|
|
||||||
safe_agent_name "$AGENT_NAME" || fail_env unsafe-agent-name MOSAIC_AGENT_NAME "$AGENT_NAME"
|
|
||||||
safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"
|
|
||||||
|
|
||||||
FLEET_DIR="$MOSAIC_HOME/fleet"
|
|
||||||
AGENT_ENV_DIR="$FLEET_DIR/agents"
|
|
||||||
assert_managed_directory "$MOSAIC_HOME"
|
|
||||||
assert_managed_directory "$FLEET_DIR"
|
|
||||||
assert_private_directory "$AGENT_ENV_DIR"
|
|
||||||
|
|
||||||
GENERATED_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.generated"
|
|
||||||
LOCAL_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.local"
|
|
||||||
|
|
||||||
declare -A GENERATED_VALUES=()
|
|
||||||
declare -A LOCAL_VALUES=()
|
|
||||||
declare -A SEEN_KEYS=()
|
|
||||||
|
|
||||||
is_sensitive_key() {
|
|
||||||
[[ "$1" =~ (API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN) ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
is_generated_key() {
|
|
||||||
case "$1" in
|
|
||||||
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
is_local_key() {
|
|
||||||
case "$1" in
|
|
||||||
MOSAIC_RUNTIME_BIN|MOSAIC_HEARTBEAT_RUN_DIR|MOSAIC_HEARTBEAT_INTERVAL|MOSAIC_CLAUDE_JSON|CLAUDE_CONFIG_DIR) return 0 ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
validate_generated_value() {
|
|
||||||
local key="$1"
|
|
||||||
local value="$2"
|
|
||||||
case "$key" in
|
|
||||||
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
|
||||||
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
|
||||||
MOSAIC_AGENT_RUNTIME)
|
|
||||||
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
|
||||||
;;
|
;;
|
||||||
MOSAIC_AGENT_MODEL) [[ "$value" =~ ^[A-Za-z0-9._/:+-]*$ ]] || fail_env unsafe-model "$key" "$value" ;;
|
|
||||||
MOSAIC_AGENT_REASONING)
|
|
||||||
case "$value" in ''|low|medium|high) ;; *) fail_env unsupported-reasoning "$key" "$value" ;; esac
|
|
||||||
;;
|
|
||||||
MOSAIC_AGENT_TOOL_POLICY) [ -z "$value" ] || safe_policy_name "$value" || fail_env unsafe-tool-policy "$key" "$value" ;;
|
|
||||||
MOSAIC_AGENT_WORKDIR) safe_path "$value" || fail_env unsafe-path "$key" "$value" ;;
|
|
||||||
MOSAIC_TMUX_SOCKET) [[ "$value" =~ ^[A-Za-z0-9_.-]*$ ]] || fail_env unsafe-socket "$key" "$value" ;;
|
|
||||||
esac
|
esac
|
||||||
}
|
|
||||||
|
|
||||||
validate_local_value() {
|
|
||||||
local key="$1"
|
|
||||||
local value="$2"
|
|
||||||
if [ "$key" = MOSAIC_HEARTBEAT_INTERVAL ]; then
|
|
||||||
[[ "$value" =~ ^[1-9][0-9]*$ ]] || fail_env invalid-interval "$key" "$value"
|
|
||||||
else
|
|
||||||
safe_path "$value" || fail_env unsafe-path "$key" "$value"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
load_environment_file() {
|
|
||||||
local file="$1"
|
|
||||||
local kind="$2"
|
|
||||||
[ -e "$file" ] || {
|
|
||||||
[ "$kind" = generated ] && fail_env missing-file '(generated)' "$file"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
assert_private_regular_file "$file"
|
|
||||||
SEEN_KEYS=()
|
|
||||||
|
|
||||||
local line key value
|
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
|
||||||
[ -z "$line" ] && continue
|
|
||||||
if [[ ! "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]]; then
|
|
||||||
fail_env malformed-line '(malformed)' "$line"
|
|
||||||
fi
|
|
||||||
key=${BASH_REMATCH[1]}
|
|
||||||
value=${BASH_REMATCH[2]}
|
|
||||||
[ -z "${SEEN_KEYS[$key]+set}" ] || fail_env duplicate-key "$key" "$value"
|
|
||||||
SEEN_KEYS[$key]=1
|
|
||||||
is_sensitive_key "$key" && fail_env sensitive-key "$key" "$value"
|
|
||||||
|
|
||||||
if [ "$kind" = generated ]; then
|
|
||||||
is_generated_key "$key" || fail_env unknown-key "$key" "$value"
|
|
||||||
validate_generated_value "$key" "$value"
|
|
||||||
GENERATED_VALUES[$key]=$value
|
|
||||||
else
|
|
||||||
is_generated_key "$key" && fail_env generated-key-shadow "$key" "$value"
|
|
||||||
is_local_key "$key" || fail_env unknown-key "$key" "$value"
|
|
||||||
validate_local_value "$key" "$value"
|
|
||||||
LOCAL_VALUES[$key]=$value
|
|
||||||
fi
|
|
||||||
done < "$file"
|
|
||||||
}
|
|
||||||
|
|
||||||
load_environment_file "$GENERATED_ENV" generated
|
|
||||||
for required_key in \
|
|
||||||
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
|
||||||
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
|
||||||
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
|
||||||
done
|
|
||||||
load_environment_file "$LOCAL_ENV" local
|
|
||||||
|
|
||||||
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
|
||||||
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
|
||||||
|
|
||||||
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
|
||||||
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
|
||||||
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
|
||||||
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
|
||||||
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
|
||||||
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
|
||||||
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
|
||||||
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
|
||||||
MOSAIC_HEARTBEAT_RUN_DIR=${LOCAL_VALUES[MOSAIC_HEARTBEAT_RUN_DIR]:-$MOSAIC_HOME/fleet/run}
|
|
||||||
MOSAIC_HEARTBEAT_INTERVAL=${LOCAL_VALUES[MOSAIC_HEARTBEAT_INTERVAL]:-15}
|
|
||||||
MOSAIC_CLAUDE_JSON=${LOCAL_VALUES[MOSAIC_CLAUDE_JSON]:-}
|
|
||||||
CLAUDE_CONFIG_DIR=${LOCAL_VALUES[CLAUDE_CONFIG_DIR]:-}
|
|
||||||
|
|
||||||
if ! command -v tmux >/dev/null 2>&1; then
|
if ! command -v tmux >/dev/null 2>&1; then
|
||||||
echo "ERROR: tmux is required" >&2
|
echo "ERROR: tmux is required" >&2
|
||||||
exit 69
|
exit 69
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# tmux wrapper: pass -L only when a socket is configured. An absent/empty socket
|
||||||
|
# means the default tmux socket (no -L), keeping spawn == observe == cheat-sheet.
|
||||||
_tmux() {
|
_tmux() {
|
||||||
if [ -n "$MOSAIC_TMUX_SOCKET" ]; then
|
if [ -n "$MOSAIC_TMUX_SOCKET" ]; then
|
||||||
tmux -L "$MOSAIC_TMUX_SOCKET" "$@"
|
tmux -L "$MOSAIC_TMUX_SOCKET" "$@"
|
||||||
@@ -210,90 +42,140 @@ _tmux() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_owned_tmux_server() {
|
|
||||||
local owner_file="$MOSAIC_HOME/fleet/run/holder-owner"
|
|
||||||
[ -f "$owner_file" ] && [ ! -L "$owner_file" ] || fail "private tmux ownership identity is missing"
|
|
||||||
local owner_mode
|
|
||||||
owner_mode=$(stat -c '%a' -- "$owner_file") || fail "private tmux ownership identity is unreadable"
|
|
||||||
(( (8#$owner_mode & 8#077) == 0 )) || fail "private tmux ownership identity has unsafe permissions"
|
|
||||||
local owner
|
|
||||||
owner=$(tr -d '\n' < "$owner_file")
|
|
||||||
[[ "$owner" =~ ^[a-f0-9-]{36}$ ]] || fail "private tmux ownership identity is malformed"
|
|
||||||
_tmux has-session -t '=_holder:0.0' 2>/dev/null || fail "owned tmux holder session is absent"
|
|
||||||
local environment expected
|
|
||||||
environment=$(_tmux show-environment -g 2>/dev/null) || fail "owned tmux global environment is unreadable"
|
|
||||||
expected=$(printf '%s\n' \
|
|
||||||
"HOME=$HOME" \
|
|
||||||
'PATH=/usr/bin:/bin' \
|
|
||||||
"PWD=$HOME" \
|
|
||||||
"MOSAIC_FLEET_OWNER=$owner" \
|
|
||||||
'MOSAIC_TMUX_HOLDER=_holder' \
|
|
||||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
|
|
||||||
[ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
|
|
||||||
fail "tmux server ownership or environment validation failed"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate exact server ownership before querying, cleaning, or creating any
|
|
||||||
# managed session. An unmanaged or contaminated named socket is never repaired.
|
|
||||||
assert_owned_tmux_server
|
|
||||||
|
|
||||||
if [ "$MODE" = interaction ]; then
|
|
||||||
[ "$MOSAIC_AGENT_RUNTIME" = pi ] || fail "operator interaction service requires runtime pi"
|
|
||||||
[ "$MOSAIC_AGENT_MODEL" = openai/gpt-5.6-sol ] || \
|
|
||||||
fail "operator interaction service requires the pinned model"
|
|
||||||
[ "$MOSAIC_AGENT_REASONING" = high ] || \
|
|
||||||
fail "operator interaction service requires high reasoning"
|
|
||||||
[ "$MOSAIC_AGENT_TOOL_POLICY" = operator-interaction ] || \
|
|
||||||
fail "operator interaction service requires the operator-interaction tool policy"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$MODE" = stop ]; then
|
|
||||||
_tmux kill-session -t "=${AGENT_NAME}" >/dev/null 2>&1 || true
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||||
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
|
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
||||||
# that home. Derive the pane home from the canonical path when available so an
|
# Map the roster's per-agent model_hint to `--model` so workers launch on the
|
||||||
# inherited pane/session HOME cannot become runtime authority.
|
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
|
||||||
PANE_HOME=$HOME
|
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}"
|
||||||
case "$MOSAIC_HOME" in
|
fi
|
||||||
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
|
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
||||||
|
# Precedence:
|
||||||
|
# 1. $MOSAIC_RUNTIME_BIN (explicit override)
|
||||||
|
# 2. $(npm config get prefix)/bin (if npm is on PATH)
|
||||||
|
# 3. Fallbacks: $HOME/.npm-global/bin and $HOME/.local/bin
|
||||||
|
#
|
||||||
|
# Only directories that already exist are included. The prefix is baked into
|
||||||
|
# the pane command regardless of what the LAUNCHER process's $PATH contains,
|
||||||
|
# because the tmux pane inherits the tmux SERVER environment (not this script's
|
||||||
|
# environment). A dir on the launcher's PATH may be absent from the server PATH,
|
||||||
|
# so every existing candidate must always be included. Dedup within the
|
||||||
|
# constructed prefix avoids listing the same dir twice.
|
||||||
_build_runtime_bin_prefix() {
|
_build_runtime_bin_prefix() {
|
||||||
local candidates=()
|
local candidates=()
|
||||||
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
|
||||||
|
if [ -n "${MOSAIC_RUNTIME_BIN:-}" ]; then
|
||||||
|
candidates+=("$MOSAIC_RUNTIME_BIN")
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v npm >/dev/null 2>&1; then
|
if command -v npm >/dev/null 2>&1; then
|
||||||
local npm_prefix
|
local npm_prefix
|
||||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||||
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
if [ -n "$npm_prefix" ]; then
|
||||||
|
candidates+=("${npm_prefix}/bin")
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")
|
|
||||||
|
|
||||||
local prefix="" dir
|
candidates+=("$HOME/.npm-global/bin")
|
||||||
|
candidates+=("$HOME/.local/bin")
|
||||||
|
|
||||||
|
local prefix=""
|
||||||
for dir in "${candidates[@]}"; do
|
for dir in "${candidates[@]}"; do
|
||||||
[ -d "$dir" ] || continue
|
[ -d "$dir" ] || continue
|
||||||
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
if [ -z "$prefix" ]; then
|
||||||
|
prefix="$dir"
|
||||||
|
else
|
||||||
|
case ":${prefix}:" in
|
||||||
|
*":${dir}:"*) ;; # already in our prefix — skip
|
||||||
|
*) prefix="${prefix}:${dir}" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
printf '%s' "$prefix"
|
printf '%s' "$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
|
|
||||||
|
|
||||||
|
# ── Build the pane command ────────────────────────────────────────────────────
|
||||||
|
# The pane command must:
|
||||||
|
# - Export the augmented PATH so the runtime binary is found.
|
||||||
|
# - exec the agent command so the runtime is the pane's foreground process
|
||||||
|
# (makes `fleet ps` pane_current_command check reliable; no DRIFT false-positive).
|
||||||
|
#
|
||||||
|
# Quoting strategy: single-quote the inner shell snippet so that variable
|
||||||
|
# references in MOSAIC_AGENT_COMMAND are NOT expanded here — they expand inside
|
||||||
|
# the pane shell. However, MOSAIC_RUNTIME_BIN_PREFIX and PATH must be expanded
|
||||||
|
# NOW (in this script) because the pane shell inherits the tmux server
|
||||||
|
# environment, not this script's env.
|
||||||
|
#
|
||||||
|
# We build the snippet as a double-quoted here-string embedded in a printf call
|
||||||
|
# to avoid nested quoting problems.
|
||||||
|
#
|
||||||
|
# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux
|
||||||
|
# server environment (not this script's, and not the systemd unit's), so the
|
||||||
|
# name would otherwise be empty in-pane and the runtime's native heartbeat
|
||||||
|
# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a
|
||||||
|
# safe single bash token regardless of the name's characters.
|
||||||
|
AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
|
||||||
|
|
||||||
|
# MOSAIC_AGENT_CLASS must ALSO be exported INTO the pane, for the same reason as
|
||||||
|
# MOSAIC_AGENT_NAME above: the pane inherits the tmux SERVER environment (not this
|
||||||
|
# script's env, and not the systemd unit's EnvironmentFile), so the per-agent class
|
||||||
|
# written to agents/<name>.env would otherwise be invisible in-pane. The launcher
|
||||||
|
# composes the persona contract from process.env.MOSAIC_AGENT_CLASS at launch
|
||||||
|
# (compose-contract -> readPersonaContractBlock); without this export it sees an
|
||||||
|
# undefined class and silently injects NO persona contract. %q-quote it so it is a
|
||||||
|
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
|
||||||
|
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
|
||||||
|
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
|
||||||
|
AGENT_TOOL_POLICY_Q=$(printf '%q' "${MOSAIC_AGENT_TOOL_POLICY:-}")
|
||||||
|
|
||||||
|
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
|
||||||
|
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
||||||
|
else
|
||||||
|
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||||
|
|
||||||
|
# ── Pre-trust the workdir for the Claude runtime ─────────────────────────────
|
||||||
|
# Claude Code shows a one-time "Is this a project you trust?" folder-trust gate
|
||||||
|
# the first time it opens a directory. A fleet-launched agent has no human to
|
||||||
|
# answer it, so the pane stalls forever at the prompt while its heartbeat keeps
|
||||||
|
# reporting "healthy" (the pane process IS alive — it's just blocked).
|
||||||
|
#
|
||||||
|
# IMPORTANT: --dangerously-skip-permissions does NOT bypass this gate, and
|
||||||
|
# neither does `trustedProjectDirectories` in settings.json (verified empirically
|
||||||
|
# 2026-06-24). The ONLY thing the gate honors is the per-project record in
|
||||||
|
# ~/.claude.json: projects["<dir>"].hasTrustDialogAccepted == true (exactly what
|
||||||
|
# answering the prompt writes). So we pre-seed that record here.
|
||||||
|
#
|
||||||
|
# Idempotent, atomic, best-effort: any failure is non-fatal (the agent still
|
||||||
|
# launches — worst case it stalls on the gate, i.e. the pre-fix status quo).
|
||||||
|
# Only the claude runtime needs this; codex/pi have no such gate.
|
||||||
_ensure_claude_workdir_trusted() {
|
_ensure_claude_workdir_trusted() {
|
||||||
local workdir="$1"
|
local workdir="$1"
|
||||||
local resolved
|
# The path claude keys on is the resolved cwd it is launched in.
|
||||||
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
local rp
|
||||||
|
rp=$(cd "$workdir" 2>/dev/null && pwd -P) || rp="$workdir"
|
||||||
|
# ~/.claude.json lives next to the claude config dir; honor CLAUDE_CONFIG_DIR.
|
||||||
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
local 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}"
|
||||||
command -v python3 >/dev/null 2>&1 || return 1
|
|
||||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
echo "WARNING: python3 not found; cannot pre-trust '$rp' for claude (agent may stall on the folder-trust gate)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Serialize concurrent agent launches that share ~/.claude.json (flock if available).
|
||||||
|
local lock="${claude_json}.mosaic-lock"
|
||||||
|
_seed() {
|
||||||
|
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$rp" python3 - <<'PY'
|
||||||
import json, os, sys, tempfile
|
import json, os, sys, tempfile
|
||||||
cj = os.environ["MOSAIC_CJ"]
|
cj = os.environ["MOSAIC_CJ"]
|
||||||
d = os.environ["MOSAIC_TRUST_DIR"]
|
d = os.environ["MOSAIC_TRUST_DIR"]
|
||||||
@@ -302,19 +184,22 @@ try:
|
|||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
data = {}
|
data = {}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# Never corrupt an unreadable/partial file — bail without writing.
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
projects = data.setdefault("projects", {})
|
projects = data.setdefault("projects", {})
|
||||||
entry = projects.get(d)
|
entry = projects.get(d)
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
entry = {}
|
entry = {}
|
||||||
projects[d] = entry
|
projects[d] = entry
|
||||||
|
if entry.get("hasTrustDialogAccepted") is True:
|
||||||
|
sys.exit(0) # already trusted — nothing to do
|
||||||
entry["hasTrustDialogAccepted"] = True
|
entry["hasTrustDialogAccepted"] = True
|
||||||
tmp_dir = os.path.dirname(cj) or "."
|
tmp_dir = os.path.dirname(cj) or "."
|
||||||
fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.")
|
fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.")
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "w") as f:
|
with os.fdopen(fd, "w") as f:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
os.replace(tmp, cj)
|
os.replace(tmp, cj) # atomic
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
os.unlink(tmp)
|
os.unlink(tmp)
|
||||||
@@ -323,55 +208,55 @@ except Exception:
|
|||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
if command -v flock >/dev/null 2>&1; then
|
||||||
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
( flock 9; _seed ) 9>"$lock" 2>/dev/null || _seed
|
||||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
|
else
|
||||||
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
_seed
|
||||||
fi
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
case "$MOSAIC_AGENT_RUNTIME" in
|
||||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
claude)
|
||||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" \
|
||||||
|
|| echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# The tmux holder owns a named server. Explicitly clear the pane environment
|
# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ────
|
||||||
# so server/session variables cannot cross the launch boundary; retain only
|
|
||||||
# trusted bootstrap, generated, and approved local data as argv assignments.
|
|
||||||
LAUNCH_ENV=(
|
|
||||||
/usr/bin/env
|
|
||||||
-i
|
|
||||||
"HOME=$PANE_HOME"
|
|
||||||
"PATH=$PANE_PATH"
|
|
||||||
"MOSAIC_HOME=$MOSAIC_HOME"
|
|
||||||
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
|
||||||
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
|
||||||
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
|
||||||
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
|
||||||
"MOSAIC_AGENT_REASONING=$MOSAIC_AGENT_REASONING"
|
|
||||||
"MOSAIC_AGENT_TOOL_POLICY=$MOSAIC_AGENT_TOOL_POLICY"
|
|
||||||
"MOSAIC_AGENT_WORKDIR=$MOSAIC_AGENT_WORKDIR"
|
|
||||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET"
|
|
||||||
"MOSAIC_HEARTBEAT_RUN_DIR=$MOSAIC_HEARTBEAT_RUN_DIR"
|
|
||||||
)
|
|
||||||
|
|
||||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
|
||||||
_tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
|
_tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
|
||||||
"${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}"
|
bash -c "$PANE_SHELL_SNIPPET"
|
||||||
|
|
||||||
|
# ── Resolve the pane PID (retry briefly to let the session initialise) ────────
|
||||||
PANE_PID=""
|
PANE_PID=""
|
||||||
for _retry in 1 2 3 4 5; do
|
for _retry in 1 2 3 4 5; do
|
||||||
PANE_PID=$(_tmux list-panes -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
PANE_PID=$(_tmux list-panes \
|
||||||
|
-t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
||||||
[ -n "$PANE_PID" ] && break
|
[ -n "$PANE_PID" ] && break
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ── Spawn the heartbeat sidecar (detached, best-effort) ──────────────────────
|
||||||
|
# The sidecar writes ~/.config/mosaic/fleet/run/<AGENT>.hb atomically while the
|
||||||
|
# pane process is alive, then exits so the file goes stale (fleet ps shows stale
|
||||||
|
# then PANE=dead). It is runtime-agnostic: it only cares about the pane PID.
|
||||||
_start_heartbeat_sidecar() {
|
_start_heartbeat_sidecar() {
|
||||||
local agent="$1" pane_pid="$2" run_dir="$3" interval="$4"
|
local agent="$1"
|
||||||
|
local pane_pid="$2"
|
||||||
|
local run_dir="$3"
|
||||||
|
local interval="$4"
|
||||||
local hb_file="${run_dir}/${agent}.hb"
|
local hb_file="${run_dir}/${agent}.hb"
|
||||||
|
|
||||||
mkdir -p "$run_dir"
|
mkdir -p "$run_dir"
|
||||||
|
|
||||||
|
# Write the sidecar as a self-contained bash one-liner so it carries no
|
||||||
|
# references to any variables from this script's environment.
|
||||||
local sidecar_script
|
local sidecar_script
|
||||||
sidecar_script=$(printf \
|
sidecar_script=$(printf \
|
||||||
'hb=%q; pid=%q; iv=%q; native="$hb.native"; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do now=$(date +%%s); marker=$(stat -c %%Y -- "$native" 2>/dev/null || true); if [ -z "$marker" ] || [ -L "$native" ] || (( now - marker > iv * 2 + 1 )); then tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; fi; sleep "$iv"; done' \
|
'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \
|
||||||
"$hb_file" "$pane_pid" "$interval")
|
"$hb_file" "$pane_pid" "$interval")
|
||||||
|
|
||||||
|
# setsid + disown ensures the sidecar survives this script exiting.
|
||||||
|
# stderr/stdout go to /dev/null; failures are non-fatal.
|
||||||
if command -v setsid >/dev/null 2>&1; then
|
if command -v setsid >/dev/null 2>&1; then
|
||||||
setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
|
setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
|
||||||
else
|
else
|
||||||
@@ -381,6 +266,7 @@ _start_heartbeat_sidecar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [ -n "$PANE_PID" ]; then
|
if [ -n "$PANE_PID" ]; then
|
||||||
|
# Guard: do not let sidecar startup failures abort the launcher (set -e).
|
||||||
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
||||||
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
||||||
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
|
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ fail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[ -n "$AGENT_NAME" ] || fail "agent name argument is required"
|
[ -n "$AGENT_NAME" ] || fail "agent name argument is required"
|
||||||
|
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "agent name contains unsupported characters"
|
||||||
|
[ "${MOSAIC_AGENT_NAME:-}" = "$AGENT_NAME" ] || fail "configured agent name must exactly match the service instance"
|
||||||
|
[ "${MOSAIC_AGENT_RUNTIME:-}" = "pi" ] || fail "operator interaction service requires runtime pi"
|
||||||
|
[ "${MOSAIC_AGENT_MODEL:-}" = "openai/gpt-5.6-sol" ] || fail "operator interaction service requires the pinned model"
|
||||||
|
[ "${MOSAIC_AGENT_REASONING:-}" = "high" ] || fail "operator interaction service requires high reasoning"
|
||||||
|
[ "${MOSAIC_AGENT_TOOL_POLICY:-}" = "operator-interaction" ] || fail "operator interaction service requires the operator-interaction tool policy"
|
||||||
|
|
||||||
# The shared launcher strictly validates the generated/local data boundary
|
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" "$AGENT_NAME"
|
||||||
# before it applies this interaction service's pinned profile checks.
|
|
||||||
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" --interaction "$AGENT_NAME"
|
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# A holder may create only the configured named socket. Existing servers are
|
|
||||||
# accepted only when their private install-derived ownership identity, exact
|
|
||||||
# holder session, and complete approved global environment all match.
|
|
||||||
|
|
||||||
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}
|
|
||||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-fleet}
|
|
||||||
MOSAIC_TMUX_HOLDER=${MOSAIC_TMUX_HOLDER:-_holder}
|
|
||||||
OWNER_FILE="$MOSAIC_HOME/fleet/run/holder-owner"
|
|
||||||
TMUX_BIN=/usr/bin/tmux
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
echo "ERROR: refusing unmanaged Mosaic tmux server on socket ${MOSAIC_TMUX_SOCKET}: $1" >&2
|
|
||||||
exit 64
|
|
||||||
}
|
|
||||||
|
|
||||||
[ -x "$TMUX_BIN" ] || fail "tmux binary is unavailable"
|
|
||||||
[ -f "$OWNER_FILE" ] && [ ! -L "$OWNER_FILE" ] || fail "private ownership identity is missing"
|
|
||||||
owner_mode=$(stat -c '%a' -- "$OWNER_FILE") || fail "private ownership identity is unreadable"
|
|
||||||
(( (8#$owner_mode & 8#077) == 0 )) || fail "private ownership identity has unsafe permissions"
|
|
||||||
MOSAIC_FLEET_OWNER=$(tr -d '\n' < "$OWNER_FILE")
|
|
||||||
[[ "$MOSAIC_FLEET_OWNER" =~ ^[a-f0-9-]{36}$ ]] || fail "private ownership identity is malformed"
|
|
||||||
|
|
||||||
_tmux() {
|
|
||||||
"$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
server_running() {
|
|
||||||
_tmux list-sessions >/dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_owned_server() {
|
|
||||||
_tmux has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || fail "exact holder session is absent"
|
|
||||||
local environment
|
|
||||||
environment=$(_tmux show-environment -g 2>/dev/null) || fail "global environment is unreadable"
|
|
||||||
local expected
|
|
||||||
expected=$(printf '%s\n' \
|
|
||||||
"HOME=$HOME" \
|
|
||||||
'PATH=/usr/bin:/bin' \
|
|
||||||
"PWD=$HOME" \
|
|
||||||
"MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \
|
|
||||||
"MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \
|
|
||||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
|
|
||||||
[ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
|
|
||||||
fail "global environment does not match the owned-server contract"
|
|
||||||
}
|
|
||||||
|
|
||||||
if server_running; then
|
|
||||||
assert_owned_server
|
|
||||||
else
|
|
||||||
cd "$HOME" || fail "trusted home is unavailable"
|
|
||||||
# Start the tmux server itself under the approved environment. The holder pane
|
|
||||||
# receives the same closed environment rather than arbitrary server globals.
|
|
||||||
/usr/bin/env -i \
|
|
||||||
"HOME=$HOME" \
|
|
||||||
PATH=/usr/bin:/bin \
|
|
||||||
"MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \
|
|
||||||
"MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \
|
|
||||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" \
|
|
||||||
"$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" \
|
|
||||||
/usr/bin/env -i "HOME=$HOME" PATH=/usr/bin:/bin /bin/sh -c 'while true; do sleep 3600; done'
|
|
||||||
fi
|
|
||||||
@@ -3,410 +3,373 @@ set -euo pipefail
|
|||||||
|
|
||||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||||
START="$SCRIPT_DIR/start-agent-session.sh"
|
START="$SCRIPT_DIR/start-agent-session.sh"
|
||||||
INTERACTION_START="$SCRIPT_DIR/start-interaction-service.sh"
|
SOCKET="mosaic-agent-test-$RANDOM-$$"
|
||||||
ROOT=$(mktemp -d)
|
AGENT="agent-$RANDOM"
|
||||||
FAKE_BIN=$(mktemp -d)
|
WORKDIR=$(mktemp -d)
|
||||||
TMUX_CALLS=$(mktemp)
|
|
||||||
trap 'rm -rf "$ROOT" "$FAKE_BIN" "$TMUX_CALLS"' EXIT
|
# Keep a single cleanup trap that accumulates resources.
|
||||||
|
CLEANUP_DIRS=("$WORKDIR")
|
||||||
|
CLEANUP_SOCKETS=("$SOCKET")
|
||||||
|
trap '_cleanup' EXIT
|
||||||
|
_cleanup() {
|
||||||
|
for s in "${CLEANUP_SOCKETS[@]:-}"; do
|
||||||
|
tmux -L "$s" kill-server >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
for d in "${CLEANUP_DIRS[@]:-}"; do
|
||||||
|
rm -rf "$d"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
echo "FAIL: $*" >&2
|
echo "FAIL: $*" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
# ── Test 1: basic session creation with workdir check ─────────────────────────
|
||||||
|
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||||
|
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||||
|
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||||
|
"$START" "$AGENT"
|
||||||
|
|
||||||
|
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
|
||||||
|
# Retry: pane_current_path briefly reflects the tmux server's cwd until the pane
|
||||||
|
# process establishes its own cwd (the -c start dir). Poll until it settles.
|
||||||
|
actual_dir=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
|
||||||
|
[ "$actual_dir" = "$WORKDIR" ] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir (expected $WORKDIR)"
|
||||||
|
|
||||||
|
# ── Test 2: idempotency (duplicate start prints 'already running') ─────────────
|
||||||
|
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||||
|
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||||
|
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||||
|
"$START" "$AGENT" >/tmp/mosaic-start-agent-idempotent.out
|
||||||
|
|
||||||
|
grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent"
|
||||||
|
|
||||||
|
# ── Test 3: runtime-bin PATH prefix is baked into the pane command ────────────
|
||||||
|
#
|
||||||
|
# We capture the command the script would hand to tmux by injecting a fake
|
||||||
|
# 'tmux' shim into PATH. The shim:
|
||||||
|
# - Intercepts 'new-session' calls and records its arguments to a file.
|
||||||
|
# - For 'has-session' calls, exits 1 (session does not exist) so the script
|
||||||
|
# proceeds to launch instead of printing "already running".
|
||||||
|
# - For 'list-panes' calls, returns empty so PANE_PID stays unset and the
|
||||||
|
# heartbeat sidecar is NOT spawned (heartbeat is not the focus of this test;
|
||||||
|
# test 6 and 7 cover that path). This prevents any real-filesystem side
|
||||||
|
# effects or leaked background processes.
|
||||||
|
# - For all other subcommands, exits 0.
|
||||||
|
#
|
||||||
|
# Assertions:
|
||||||
|
# a) 'export PATH=' with the synthetic MOSAIC_RUNTIME_BIN prefix appears.
|
||||||
|
# b) 'exec' appears so the runtime replaces the wrapper shell.
|
||||||
|
# c) MOSAIC_AGENT_COMMAND with flags is forwarded intact.
|
||||||
|
|
||||||
|
FAKE_BIN=$(mktemp -d)
|
||||||
|
FAKE_RUNTIME_BIN=$(mktemp -d)
|
||||||
|
TMUX_ARGS_FILE=$(mktemp)
|
||||||
|
HB_RUN_DIR3=$(mktemp -d)
|
||||||
|
CLEANUP_DIRS+=("$FAKE_BIN" "$FAKE_RUNTIME_BIN" "$HB_RUN_DIR3")
|
||||||
|
|
||||||
|
# Write the fake tmux shim (uses only positional args, no sourced vars).
|
||||||
|
cat > "$FAKE_BIN/tmux" <<SHIM
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
# Fake tmux: record new-session args; report has-session as missing.
|
||||||
printf '%s\0' "$@" >> "${MOSAIC_TEST_TMUX_CALLS:?}"
|
subcmd="\$3" # argv: tmux -L <socket> <subcmd> ...
|
||||||
args=("$@")
|
if [ "\$subcmd" = "has-session" ]; then
|
||||||
index=0
|
exit 1 # session not found → script will attempt new-session
|
||||||
if [ "${args[0]:-}" = -L ]; then index=2; fi
|
|
||||||
case "${args[$index]:-}" in
|
|
||||||
has-session)
|
|
||||||
for argument in "${args[@]}"; do
|
|
||||||
[ "$argument" = '=_holder:0.0' ] && exit 0
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
show-environment)
|
|
||||||
printf '%s\n' \
|
|
||||||
"HOME=${MOSAIC_TEST_HOME:?}" \
|
|
||||||
'PATH=/usr/bin:/bin' \
|
|
||||||
"PWD=${MOSAIC_TEST_HOME:?}" \
|
|
||||||
"MOSAIC_FLEET_OWNER=${MOSAIC_TEST_FLEET_OWNER:?}" \
|
|
||||||
'MOSAIC_TMUX_HOLDER=_holder' \
|
|
||||||
'MOSAIC_TMUX_SOCKET=mosaic-test'
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
list-panes) printf '%s\n' "${MOSAIC_TEST_PANE_PID:-}"; exit 0 ;;
|
|
||||||
new-session)
|
|
||||||
if [ "${MOSAIC_TEST_EXECUTE_PANE:-}" = 1 ]; then
|
|
||||||
for ((index = 0; index < ${#args[@]}; index++)); do
|
|
||||||
if [ "${args[$index]}" = /usr/bin/env ]; then
|
|
||||||
"${args[@]:$index}"
|
|
||||||
break
|
|
||||||
fi
|
fi
|
||||||
done
|
if [ "\$subcmd" = "new-session" ]; then
|
||||||
|
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ "\$subcmd" = "list-panes" ]; then
|
||||||
|
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||||
|
echo ""
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
|
||||||
*) exit 0 ;;
|
|
||||||
esac
|
|
||||||
SHIM
|
SHIM
|
||||||
chmod +x "$FAKE_BIN/tmux"
|
chmod +x "$FAKE_BIN/tmux"
|
||||||
|
|
||||||
cat > "$FAKE_BIN/mosaic" <<'SHIM'
|
SOCKET3="mosaic-agent-test3-$RANDOM-$$"
|
||||||
|
AGENT3="agent3-$RANDOM"
|
||||||
|
WORKDIR3=$(mktemp -d)
|
||||||
|
CLEANUP_DIRS+=("$WORKDIR3")
|
||||||
|
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
MOSAIC_TMUX_SOCKET="$SOCKET3" \
|
||||||
|
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
|
||||||
|
MOSAIC_AGENT_RUNTIME="pi" \
|
||||||
|
MOSAIC_AGENT_CLASS="code" \
|
||||||
|
MOSAIC_AGENT_TOOL_POLICY="operator-interaction" \
|
||||||
|
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
|
||||||
|
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
|
||||||
|
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
|
||||||
|
"$START" "$AGENT3"
|
||||||
|
|
||||||
|
all_args=$(cat "$TMUX_ARGS_FILE" 2>/dev/null || true)
|
||||||
|
rm -f "$TMUX_ARGS_FILE"
|
||||||
|
|
||||||
|
echo "--- captured tmux new-session args ---"
|
||||||
|
echo "$all_args"
|
||||||
|
echo "--- end args ---"
|
||||||
|
|
||||||
|
# a) PATH prefix containing FAKE_RUNTIME_BIN must appear.
|
||||||
|
echo "$all_args" | grep -qF "export PATH=" || fail "pane command does not export PATH"
|
||||||
|
echo "$all_args" | grep -qF "$FAKE_RUNTIME_BIN" || fail "pane command does not include MOSAIC_RUNTIME_BIN in PATH prefix"
|
||||||
|
|
||||||
|
# b) exec must appear so the runtime replaces the wrapper shell.
|
||||||
|
echo "$all_args" | grep -qF "exec " || fail "pane command does not use exec"
|
||||||
|
|
||||||
|
# c) Full MOSAIC_AGENT_COMMAND (with flags) must be forwarded.
|
||||||
|
echo "$all_args" | grep -qF "mosaic yolo pi --model openai-codex/gpt-5.5:high" || \
|
||||||
|
fail "pane command does not forward MOSAIC_AGENT_COMMAND with flags intact"
|
||||||
|
|
||||||
|
# d) MOSAIC_AGENT_NAME and the per-agent MOSAIC_AGENT_CLASS must BOTH be exported
|
||||||
|
# INTO the pane. The pane inherits the tmux SERVER environment (not this
|
||||||
|
# script's env, nor the systemd unit's EnvironmentFile), so any per-agent var
|
||||||
|
# the launcher needs in-pane must be re-exported in the snippet. CLASS is
|
||||||
|
# load-bearing: the launcher composes the persona contract from
|
||||||
|
# process.env.MOSAIC_AGENT_CLASS, so a missing export silently drops the
|
||||||
|
# persona (regression guard for the A3a pane-propagation gap).
|
||||||
|
echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \
|
||||||
|
fail "pane command does not export MOSAIC_AGENT_NAME into the pane"
|
||||||
|
echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \
|
||||||
|
fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)"
|
||||||
|
echo "$all_args" | grep -qF "export MOSAIC_AGENT_TOOL_POLICY=operator-interaction" || \
|
||||||
|
fail "pane command does not export MOSAIC_AGENT_TOOL_POLICY into the pane"
|
||||||
|
|
||||||
|
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
|
||||||
|
TMUX_ARGS_FILE2=$(mktemp)
|
||||||
|
FAKE_BIN2=$(mktemp -d)
|
||||||
|
HB_RUN_DIR4=$(mktemp -d)
|
||||||
|
CLEANUP_DIRS+=("$FAKE_BIN2" "$HB_RUN_DIR4")
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN2/tmux" <<SHIM2
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
subcmd="\$3"
|
||||||
env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||||
SHIM
|
if [ "\$subcmd" = "new-session" ]; then
|
||||||
chmod +x "$FAKE_BIN/mosaic"
|
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE2"
|
||||||
|
exit 0
|
||||||
write_generated() {
|
|
||||||
local home="$1"
|
|
||||||
local agent="$2"
|
|
||||||
mkdir -p "$home/fleet/agents" "$home/fleet/run"
|
|
||||||
chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run"
|
|
||||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner"
|
|
||||||
chmod 600 "$home/fleet/run/holder-owner"
|
|
||||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
|
||||||
MOSAIC_AGENT_NAME=$agent
|
|
||||||
MOSAIC_AGENT_CLASS=code
|
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
|
||||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
|
||||||
MOSAIC_AGENT_REASONING=high
|
|
||||||
MOSAIC_AGENT_TOOL_POLICY=code
|
|
||||||
MOSAIC_AGENT_WORKDIR=$home/work
|
|
||||||
MOSAIC_TMUX_SOCKET=mosaic-test
|
|
||||||
EOF
|
|
||||||
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
|
||||||
mkdir -p "$home/work"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_start() {
|
|
||||||
local home="$1"
|
|
||||||
local agent="$2"
|
|
||||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
|
||||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
|
||||||
MOSAIC_TEST_HOME="$home" \
|
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
|
||||||
MOSAIC_HOME="$home" "$START" "$agent"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Valid generated data launches only the fixed runtime argument array. It never
|
|
||||||
# reads an agent-command string or constructs a bash -c pane payload.
|
|
||||||
HOME_VALID="$ROOT/valid"
|
|
||||||
AGENT_VALID="coder0"
|
|
||||||
write_generated "$HOME_VALID" "$AGENT_VALID"
|
|
||||||
run_start "$HOME_VALID" "$AGENT_VALID"
|
|
||||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
|
||||||
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
|
||||||
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
|
||||||
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
|
|
||||||
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
|
|
||||||
if echo "$valid_args" | grep -qF 'bash -c'; then
|
|
||||||
fail "launcher constructed a shell command payload"
|
|
||||||
fi
|
fi
|
||||||
|
if [ "\$subcmd" = "list-panes" ]; then
|
||||||
# The pane must start through an absolute clean-environment boundary. Its
|
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||||
# runtime command remains an argv vector, but no holder/session environment
|
echo ""
|
||||||
# control variable can pass through the pane command.
|
exit 0
|
||||||
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
|
||||||
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
|
||||||
|
|
||||||
# The generated-file parent is a security boundary too: even a private regular
|
|
||||||
# file is untrusted if its parent can be replaced or written by another user.
|
|
||||||
# Validation must happen before fake tmux receives even a has-session call.
|
|
||||||
: > "$TMUX_CALLS"
|
|
||||||
HOME_UNSAFE_PARENT="$ROOT/unsafe-parent"
|
|
||||||
write_generated "$HOME_UNSAFE_PARENT" "coder-parent"
|
|
||||||
chmod 777 "$HOME_UNSAFE_PARENT/fleet/agents"
|
|
||||||
if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
|
|
||||||
fail "generated file under a world-writable parent was accepted"
|
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
exit 0
|
||||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
SHIM2
|
||||||
|
chmod +x "$FAKE_BIN2/tmux"
|
||||||
|
|
||||||
: > "$TMUX_CALLS"
|
SOCKET4="mosaic-agent-test4-$RANDOM-$$"
|
||||||
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
AGENT4="agent4-$RANDOM"
|
||||||
write_generated "$HOME_SYMLINK_PARENT" "coder-symlink-parent"
|
WORKDIR4=$(mktemp -d)
|
||||||
mv "$HOME_SYMLINK_PARENT/fleet/agents" "$HOME_SYMLINK_PARENT/private-agents"
|
CLEANUP_DIRS+=("$WORKDIR4")
|
||||||
ln -s "$HOME_SYMLINK_PARENT/private-agents" "$HOME_SYMLINK_PARENT/fleet/agents"
|
|
||||||
if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
|
# MOSAIC_RUNTIME_BIN points to a non-existent dir so prefix will be empty;
|
||||||
fail "generated file under a symlinked parent was accepted"
|
# .npm-global/bin and .local/bin may or may not exist but we just want exec.
|
||||||
|
PATH="$FAKE_BIN2:$PATH" \
|
||||||
|
MOSAIC_TMUX_SOCKET="$SOCKET4" \
|
||||||
|
MOSAIC_AGENT_WORKDIR="$WORKDIR4" \
|
||||||
|
MOSAIC_AGENT_RUNTIME="pi" \
|
||||||
|
MOSAIC_RUNTIME_BIN="/nonexistent-dir-$$" \
|
||||||
|
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||||
|
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR4" \
|
||||||
|
"$START" "$AGENT4"
|
||||||
|
|
||||||
|
all_args4=$(cat "$TMUX_ARGS_FILE2" 2>/dev/null || true)
|
||||||
|
rm -f "$TMUX_ARGS_FILE2"
|
||||||
|
rm -rf "$WORKDIR4"
|
||||||
|
|
||||||
|
echo "$all_args4" | grep -qF "exec " || fail "pane command (no prefix dirs) does not use exec"
|
||||||
|
echo "$all_args4" | grep -qF "mosaic yolo pi" || fail "pane command does not include agent command when no prefix"
|
||||||
|
|
||||||
|
# ── Test 5: candidate dir already in LAUNCHER $PATH is still baked into pane ──
|
||||||
|
#
|
||||||
|
# Regression guard for the bug where _build_runtime_bin_prefix() used to skip
|
||||||
|
# a candidate because it was already present in the launcher process's $PATH.
|
||||||
|
# That check was wrong: the pane inherits the tmux SERVER environment, not the
|
||||||
|
# launcher's env. Even if a dir is on the launcher's PATH it must always be
|
||||||
|
# baked into the pane's PATH export.
|
||||||
|
#
|
||||||
|
# We prove this by setting PATH to include FAKE_RUNTIME_BIN5 (the candidate),
|
||||||
|
# then asserting the generated new-session command still exports it.
|
||||||
|
TMUX_ARGS_FILE5=$(mktemp)
|
||||||
|
FAKE_BIN5=$(mktemp -d)
|
||||||
|
FAKE_RUNTIME_BIN5=$(mktemp -d) # this dir IS on the launcher's PATH below
|
||||||
|
HB_RUN_DIR5=$(mktemp -d)
|
||||||
|
CLEANUP_DIRS+=("$FAKE_BIN5" "$FAKE_RUNTIME_BIN5" "$HB_RUN_DIR5")
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN5/tmux" <<SHIM5
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
subcmd="\$3"
|
||||||
|
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||||
|
if [ "\$subcmd" = "new-session" ]; then
|
||||||
|
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE5"
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
if [ "\$subcmd" = "list-panes" ]; then
|
||||||
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||||
|
echo ""
|
||||||
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
exit 0
|
||||||
# symlink or group/world-writable ancestor must fail before environment parsing,
|
|
||||||
# workdir creation, or tmux effects. The malformed local input proves parsing
|
|
||||||
# was not reached when the ancestor rejection is reported.
|
|
||||||
assert_managed_ancestor_rejected() {
|
|
||||||
local ancestor="$1"
|
|
||||||
local hazard="$2"
|
|
||||||
local home="$ROOT/managed-${ancestor//\//-}-${hazard}"
|
|
||||||
local agent="coder-managed-${ancestor//\//-}-${hazard}"
|
|
||||||
local node
|
|
||||||
write_generated "$home" "$agent"
|
|
||||||
printf 'MOSAIC_AGENT_COMMAND=must-not-be-parsed\n' > "$home/fleet/agents/$agent.env.local"
|
|
||||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
|
||||||
rm -rf "$home/work"
|
|
||||||
|
|
||||||
case "$ancestor" in
|
|
||||||
MOSAIC_HOME) node="$home" ;;
|
|
||||||
MOSAIC_HOME/fleet) node="$home/fleet" ;;
|
|
||||||
MOSAIC_HOME/fleet/agents) node="$home/fleet/agents" ;;
|
|
||||||
*) fail "unknown managed ancestor: $ancestor" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ "$hazard" = symlink ]; then
|
|
||||||
local target="${node}-target"
|
|
||||||
mv "$node" "$target"
|
|
||||||
ln -s "$target" "$node"
|
|
||||||
else
|
|
||||||
chmod 777 "$node"
|
|
||||||
fi
|
fi
|
||||||
|
exit 0
|
||||||
|
SHIM5
|
||||||
|
chmod +x "$FAKE_BIN5/tmux"
|
||||||
|
|
||||||
: > "$TMUX_CALLS"
|
SOCKET5="mosaic-agent-test5-$RANDOM-$$"
|
||||||
if output=$(run_start "$home" "$agent" 2>&1); then
|
AGENT5="agent5-$RANDOM"
|
||||||
fail "${hazard} $ancestor was accepted"
|
WORKDIR5=$(mktemp -d)
|
||||||
fi
|
CLEANUP_DIRS+=("$WORKDIR5")
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
CLEANUP_SOCKETS+=("$SOCKET5")
|
||||||
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
|
|
||||||
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
|
|
||||||
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
|
|
||||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
for managed_ancestor in MOSAIC_HOME MOSAIC_HOME/fleet MOSAIC_HOME/fleet/agents; do
|
# FAKE_RUNTIME_BIN5 is deliberately placed on the LAUNCHER PATH so that the
|
||||||
assert_managed_ancestor_rejected "$managed_ancestor" symlink
|
# old (buggy) code would have skipped it. The correct code must still include
|
||||||
assert_managed_ancestor_rejected "$managed_ancestor" group-world-writable
|
# it in the pane PATH export.
|
||||||
|
PATH="$FAKE_BIN5:$FAKE_RUNTIME_BIN5:$PATH" \
|
||||||
|
MOSAIC_TMUX_SOCKET="$SOCKET5" \
|
||||||
|
MOSAIC_AGENT_WORKDIR="$WORKDIR5" \
|
||||||
|
MOSAIC_AGENT_RUNTIME="pi" \
|
||||||
|
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN5" \
|
||||||
|
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||||
|
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR5" \
|
||||||
|
"$START" "$AGENT5"
|
||||||
|
|
||||||
|
all_args5=$(cat "$TMUX_ARGS_FILE5" 2>/dev/null || true)
|
||||||
|
rm -f "$TMUX_ARGS_FILE5"
|
||||||
|
rm -rf "$WORKDIR5"
|
||||||
|
|
||||||
|
echo "--- test 5: launcher-PATH candidate must still appear in pane export ---"
|
||||||
|
echo "$all_args5"
|
||||||
|
echo "--- end test 5 args ---"
|
||||||
|
|
||||||
|
echo "$all_args5" | grep -qF "export PATH=" || \
|
||||||
|
fail "test5: pane command does not export PATH when candidate is on launcher PATH"
|
||||||
|
echo "$all_args5" | grep -qF "$FAKE_RUNTIME_BIN5" || \
|
||||||
|
fail "test5: candidate dir (already on launcher PATH) was NOT baked into pane PATH — regression"
|
||||||
|
|
||||||
|
# ── Test 6: heartbeat sidecar — pane PID resolved + .hb file written ──────────
|
||||||
|
#
|
||||||
|
# Uses a real tmux session (same socket as test 1 which already has $AGENT) so
|
||||||
|
# list-panes returns a real pane PID. We override MOSAIC_HEARTBEAT_RUN_DIR to
|
||||||
|
# a temp dir and set a 1-second interval, then wait up to 3 s for the .hb file
|
||||||
|
# to appear and check its content.
|
||||||
|
|
||||||
|
HB_RUN_DIR=$(mktemp -d)
|
||||||
|
CLEANUP_DIRS+=("$HB_RUN_DIR")
|
||||||
|
|
||||||
|
# Re-use the session+agent created in Test 1 (still alive on $SOCKET / $AGENT).
|
||||||
|
# We need to invoke the script for a NEW agent on the same socket to exercise
|
||||||
|
# the heartbeat path with a real pane PID.
|
||||||
|
AGENT6="agent6-$RANDOM"
|
||||||
|
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||||
|
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||||
|
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||||
|
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR" \
|
||||||
|
MOSAIC_HEARTBEAT_INTERVAL="1" \
|
||||||
|
"$START" "$AGENT6"
|
||||||
|
|
||||||
|
HB_FILE="$HB_RUN_DIR/${AGENT6}.hb"
|
||||||
|
|
||||||
|
# Wait up to 5 seconds for the heartbeat file to appear.
|
||||||
|
_waited=0
|
||||||
|
until [ -f "$HB_FILE" ] || [ "$_waited" -ge 5 ]; do
|
||||||
|
sleep 0.5
|
||||||
|
_waited=$((_waited + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
# A local file cannot shadow any roster-derived generated key. Validation must
|
[ -f "$HB_FILE" ] || fail "test6: heartbeat file not written at $HB_FILE within 5s"
|
||||||
# happen before fake tmux receives even a has-session call.
|
|
||||||
: > "$TMUX_CALLS"
|
|
||||||
HOME_SHADOW="$ROOT/shadow"
|
|
||||||
write_generated "$HOME_SHADOW" "coder1"
|
|
||||||
printf 'MOSAIC_AGENT_RUNTIME=codex\n' > "$HOME_SHADOW/fleet/agents/coder1.env.local"
|
|
||||||
chmod 600 "$HOME_SHADOW/fleet/agents/coder1.env.local"
|
|
||||||
if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
|
|
||||||
fail "generated-key shadow was accepted"
|
|
||||||
fi
|
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
|
||||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
|
||||||
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
|
|
||||||
if echo "$output" | grep -qF 'codex'; then
|
|
||||||
fail "shadow diagnostic leaked value"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Arbitrary command compatibility is quarantined/rejected as data. Diagnostics
|
hb_content=$(cat "$HB_FILE")
|
||||||
# may name the key and hash but must never echo the privileged command text.
|
echo "--- test 6: heartbeat file content ---"
|
||||||
: > "$TMUX_CALLS"
|
echo "$hb_content"
|
||||||
HOME_COMMAND="$ROOT/command"
|
echo "--- end test 6 ---"
|
||||||
write_generated "$HOME_COMMAND" "coder2"
|
|
||||||
COMMAND_VALUE='mosaic yolo codex --dangerous'
|
|
||||||
printf 'MOSAIC_AGENT_COMMAND=%s\n' "$COMMAND_VALUE" > "$HOME_COMMAND/fleet/agents/coder2.env.local"
|
|
||||||
chmod 600 "$HOME_COMMAND/fleet/agents/coder2.env.local"
|
|
||||||
if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
|
|
||||||
fail "arbitrary command override was accepted"
|
|
||||||
fi
|
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
|
||||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
|
||||||
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
|
|
||||||
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
|
|
||||||
fail "command diagnostic leaked command value"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Group/world-readable local input is not trusted even when its syntax is safe.
|
# Verify required fields are present.
|
||||||
: > "$TMUX_CALLS"
|
echo "$hb_content" | grep -qE '^ts=[0-9]{4}-[0-9]{2}-[0-9]{2}T' || \
|
||||||
HOME_PERMS="$ROOT/perms"
|
fail "test6: heartbeat ts field missing or malformed"
|
||||||
write_generated "$HOME_PERMS" "coder3"
|
echo "$hb_content" | grep -qE '^pid=[0-9]+' || \
|
||||||
printf 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n' > "$HOME_PERMS/fleet/agents/coder3.env.local"
|
fail "test6: heartbeat pid field missing or malformed"
|
||||||
chmod 644 "$HOME_PERMS/fleet/agents/coder3.env.local"
|
echo "$hb_content" | grep -qF 'status=ok' || \
|
||||||
if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
|
fail "test6: heartbeat status=ok missing"
|
||||||
fail "world-readable local input was accepted"
|
|
||||||
fi
|
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
|
||||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
|
||||||
|
|
||||||
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
|
# ── Test 7: heartbeat sidecar — targets correct .hb path per agent name ────────
|
||||||
# computed PATH only. The pane command itself must not carry loader, shell
|
#
|
||||||
# control, arbitrary sentinel, or stale bootstrap variables.
|
# Uses the fake-tmux shim approach (like tests 3-5) to capture the sidecar
|
||||||
: > "$TMUX_CALLS"
|
# invocation without needing a real session. A fake setsid shim records its
|
||||||
HOME_PANE_BOUNDARY="$ROOT/pane-boundary/.config/mosaic"
|
# arguments so we can assert the sidecar script targets the expected .hb path
|
||||||
write_generated "$HOME_PANE_BOUNDARY" "coder-pane-boundary"
|
# and uses the configured interval.
|
||||||
PANE_TRUSTED_HOME="${HOME_PANE_BOUNDARY%/.config/mosaic}"
|
|
||||||
PANE_STALE_HOME="$ROOT/stale-home"
|
|
||||||
PANE_STALE_PATH="$ROOT/stale-bin"
|
|
||||||
PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
|
|
||||||
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
|
|
||||||
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
|
||||||
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
|
||||||
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
|
|
||||||
BASH_ENV="$PANE_BASH_ENV" \
|
|
||||||
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
|
|
||||||
HOME="$PANE_STALE_HOME" \
|
|
||||||
PATH="$PANE_STALE_PATH" \
|
|
||||||
/usr/bin/env -i \
|
|
||||||
"HOME=$PANE_TRUSTED_HOME" \
|
|
||||||
"PATH=$FAKE_BIN:/usr/bin:/bin" \
|
|
||||||
"MOSAIC_HOME=$HOME_PANE_BOUNDARY" \
|
|
||||||
"MOSAIC_TEST_TMUX_CALLS=$TMUX_CALLS" \
|
|
||||||
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
|
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
|
||||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
|
||||||
"$START" coder-pane-boundary
|
|
||||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
|
||||||
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
|
||||||
fail "pane did not restore trusted HOME"
|
|
||||||
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
|
|
||||||
fail "pane inherited stale HOME"
|
|
||||||
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
|
||||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
|
||||||
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
|
|
||||||
done
|
|
||||||
|
|
||||||
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
|
FAKE_BIN7=$(mktemp -d)
|
||||||
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
|
FAKE_RUNTIME_BIN7=$(mktemp -d)
|
||||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
SETSID_ARGS_FILE=$(mktemp)
|
||||||
fail "pane command did not clear its environment"
|
HB_RUN_DIR7=$(mktemp -d)
|
||||||
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
CLEANUP_DIRS+=("$FAKE_BIN7" "$FAKE_RUNTIME_BIN7" "$HB_RUN_DIR7")
|
||||||
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
|
||||||
fail "runtime pane did not receive trusted HOME"
|
|
||||||
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
|
||||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
|
||||||
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
|
||||||
done
|
|
||||||
|
|
||||||
write_interaction_generated() {
|
AGENT7="my-fleet-agent-$RANDOM"
|
||||||
local home="$1"
|
INTERVAL7="42"
|
||||||
local agent="$2"
|
|
||||||
mkdir -p "$home/fleet/agents" "$home/fleet/run" "$home/work"
|
|
||||||
chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run"
|
|
||||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner"
|
|
||||||
chmod 600 "$home/fleet/run/holder-owner"
|
|
||||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
|
||||||
MOSAIC_AGENT_NAME=$agent
|
|
||||||
MOSAIC_AGENT_CLASS=operator-interaction
|
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
|
||||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
|
||||||
MOSAIC_AGENT_REASONING=high
|
|
||||||
MOSAIC_AGENT_TOOL_POLICY=operator-interaction
|
|
||||||
MOSAIC_AGENT_WORKDIR=$home/work
|
|
||||||
MOSAIC_TMUX_SOCKET=mosaic-test
|
|
||||||
EOF
|
|
||||||
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_interaction() {
|
# Fake tmux: has-session → not found; new-session → ok; list-panes → known PID.
|
||||||
local home="$1"
|
cat > "$FAKE_BIN7/tmux" <<SHIM7
|
||||||
local agent="$2"
|
#!/usr/bin/env bash
|
||||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
subcmd="\$3"
|
||||||
MOSAIC_TEST_HOME="$home" \
|
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
if [ "\$subcmd" = "new-session" ]; then exit 0; fi
|
||||||
MOSAIC_HOME="$home" "$INTERACTION_START" "$agent"
|
if [ "\$subcmd" = "list-panes" ]; then echo "88888"; exit 0; fi
|
||||||
}
|
exit 0
|
||||||
|
SHIM7
|
||||||
|
chmod +x "$FAKE_BIN7/tmux"
|
||||||
|
|
||||||
write_heartbeat_local() {
|
# Fake setsid: capture the bash -c <script> argument for inspection, then
|
||||||
local home="$1"
|
# background an actual bash subshell so disown succeeds in the caller.
|
||||||
local agent="$2"
|
cat > "$FAKE_BIN7/setsid" <<'SETSID_SHIM'
|
||||||
mkdir -p "$home/run"
|
#!/usr/bin/env bash
|
||||||
cat > "$home/fleet/agents/$agent.env.local" <<EOF
|
# argv: setsid bash -c <sidecar_script>
|
||||||
MOSAIC_HEARTBEAT_RUN_DIR=$home/run
|
# Record the full argument list to the capture file, then exit cleanly.
|
||||||
MOSAIC_HEARTBEAT_INTERVAL=1
|
printf '%s\0' "$@" > __SETSID_ARGS_FILE__
|
||||||
EOF
|
exit 0
|
||||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
SETSID_SHIM
|
||||||
}
|
# Patch the placeholder with the real capture-file path (avoids heredoc expansion issues).
|
||||||
|
sed -i "s|__SETSID_ARGS_FILE__|${SETSID_ARGS_FILE}|g" "$FAKE_BIN7/setsid"
|
||||||
|
chmod +x "$FAKE_BIN7/setsid"
|
||||||
|
|
||||||
wait_for_sidecar_status() {
|
SOCKET7="mosaic-agent-test7-$RANDOM-$$"
|
||||||
local file="$1"
|
WORKDIR7=$(mktemp -d)
|
||||||
for _retry in $(seq 1 30); do
|
CLEANUP_DIRS+=("$WORKDIR7")
|
||||||
grep -qF 'status=ok' "$file" 2>/dev/null && return 0
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
fail "heartbeat sidecar did not resume after native marker became stale or absent"
|
|
||||||
}
|
|
||||||
|
|
||||||
# A fresh Pi-native marker is authoritative: the shell sidecar may start but
|
PATH="$FAKE_BIN7:$PATH" \
|
||||||
# must not overwrite Pi's busy/ok/model heartbeat. It must resume only when
|
MOSAIC_TMUX_SOCKET="$SOCKET7" \
|
||||||
# the marker is stale or absent.
|
MOSAIC_AGENT_WORKDIR="$WORKDIR7" \
|
||||||
HOME_NATIVE_FRESH="$ROOT/native-fresh"
|
MOSAIC_AGENT_RUNTIME="pi" \
|
||||||
write_generated "$HOME_NATIVE_FRESH" "coder-native-fresh"
|
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN7" \
|
||||||
write_heartbeat_local "$HOME_NATIVE_FRESH" "coder-native-fresh"
|
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||||
FRESH_HB="$HOME_NATIVE_FRESH/run/coder-native-fresh.hb"
|
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR7" \
|
||||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=authoritative-model\n' > "$FRESH_HB"
|
MOSAIC_HEARTBEAT_INTERVAL="$INTERVAL7" \
|
||||||
touch "$FRESH_HB.native"
|
"$START" "$AGENT7"
|
||||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_FRESH" coder-native-fresh
|
|
||||||
sleep 0.3
|
|
||||||
fresh_content=$(cat "$FRESH_HB")
|
|
||||||
[ "$fresh_content" = 'ts=native
|
|
||||||
pid=1
|
|
||||||
status=busy
|
|
||||||
model=authoritative-model' ] || fail "fresh native heartbeat was overwritten"
|
|
||||||
|
|
||||||
HOME_NATIVE_STALE="$ROOT/native-stale"
|
# Give the background setsid shim a moment to finish writing the capture file.
|
||||||
write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
|
sleep 0.5
|
||||||
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
|
||||||
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
|
||||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
|
||||||
touch -d '10 seconds ago' "$STALE_HB.native"
|
|
||||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
|
||||||
wait_for_sidecar_status "$STALE_HB"
|
|
||||||
|
|
||||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
setsid_args=$(cat "$SETSID_ARGS_FILE" 2>/dev/null | tr '\0' '\n' || true)
|
||||||
write_generated "$HOME_NATIVE_ABSENT" "coder-native-absent"
|
rm -f "$SETSID_ARGS_FILE"
|
||||||
write_heartbeat_local "$HOME_NATIVE_ABSENT" "coder-native-absent"
|
rm -rf "$WORKDIR7"
|
||||||
ABSENT_HB="$HOME_NATIVE_ABSENT/run/coder-native-absent.hb"
|
|
||||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=absent-model\n' > "$ABSENT_HB"
|
|
||||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_ABSENT" coder-native-absent
|
|
||||||
wait_for_sidecar_status "$ABSENT_HB"
|
|
||||||
|
|
||||||
# The interaction wrapper delegates to the shared strict parser before applying
|
echo "--- test 7: captured setsid args ---"
|
||||||
# its pinned policy, so malformed projection data wins over profile diagnostics.
|
echo "$setsid_args"
|
||||||
: > "$TMUX_CALLS"
|
echo "--- end test 7 ---"
|
||||||
HOME_INTERACTION_MALFORMED="$ROOT/interaction-malformed"
|
|
||||||
write_interaction_generated "$HOME_INTERACTION_MALFORMED" "interaction-malformed"
|
|
||||||
printf 'UNTRUSTED_BOOTSTRAP=value\n' >> "$HOME_INTERACTION_MALFORMED/fleet/agents/interaction-malformed.env.generated"
|
|
||||||
if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed 2>&1); then
|
|
||||||
fail "interaction wrapper accepted malformed generated data"
|
|
||||||
fi
|
|
||||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
|
||||||
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
|
||||||
|
|
||||||
# A syntactically valid but policy-incompatible projection reaches the pinned
|
# The sidecar script (bash -c <script>) must reference the correct .hb path.
|
||||||
# interaction policy check only after strict parsing and never starts tmux.
|
expected_hb="${HB_RUN_DIR7}/${AGENT7}.hb"
|
||||||
: > "$TMUX_CALLS"
|
echo "$setsid_args" | grep -qF "$expected_hb" || \
|
||||||
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
fail "test7: sidecar script does not reference correct .hb path ($expected_hb)"
|
||||||
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
|
||||||
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \
|
|
||||||
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
|
||||||
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
|
||||||
fail "interaction wrapper accepted a policy-incompatible projection"
|
|
||||||
fi
|
|
||||||
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
|
||||||
echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
|
||||||
fail "interaction pinned-policy rejection created a tmux session"
|
|
||||||
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
|
||||||
fail "interaction pinned-policy check did not follow strict parsing"
|
|
||||||
|
|
||||||
# Exact stop derives the socket exclusively from the validated generated
|
# The sidecar script must use the configured interval.
|
||||||
# projection and ignores an ambient socket supplied by the caller.
|
echo "$setsid_args" | grep -qF "$INTERVAL7" || \
|
||||||
: > "$TMUX_CALLS"
|
fail "test7: sidecar script does not reference configured interval ($INTERVAL7)"
|
||||||
HOME_STOP="$ROOT/stop"
|
|
||||||
write_generated "$HOME_STOP" "coder-stop"
|
|
||||||
HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
|
||||||
MOSAIC_TEST_HOME="$HOME_STOP" \
|
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
|
||||||
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
|
||||||
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
|
||||||
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
|
|
||||||
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
|
|
||||||
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
|
||||||
if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
|
||||||
fail "exact stop trusted an ambient socket"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo 'ok - start-agent-session generated environment boundary'
|
echo "ok - start-agent-session"
|
||||||
|
|||||||
@@ -1,657 +0,0 @@
|
|||||||
import { chmod, mkdir, mkdtemp, readFile, rename, 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 { fleetAgentMutationExitCode } from './fleet-agent-crud-command.js';
|
|
||||||
import { registerFleetCommand, type FleetCommandDeps } from './fleet.js';
|
|
||||||
|
|
||||||
const roster = `
|
|
||||||
version: 2
|
|
||||||
generation: 7
|
|
||||||
transport: tmux
|
|
||||||
tmux:
|
|
||||||
socket_name: mosaic-fleet
|
|
||||||
holder_session: _holder
|
|
||||||
defaults:
|
|
||||||
working_directory: /srv/mosaic
|
|
||||||
runtime: pi
|
|
||||||
runtimes:
|
|
||||||
pi:
|
|
||||||
reset_command: /new
|
|
||||||
agents:
|
|
||||||
- name: orchestrator
|
|
||||||
alias: Orchestrator
|
|
||||||
class: orchestrator
|
|
||||||
runtime: pi
|
|
||||||
provider: openai
|
|
||||||
model: gpt-5.6-sol
|
|
||||||
reasoning: high
|
|
||||||
tool_policy: orchestrator
|
|
||||||
working_directory: /srv/mosaic
|
|
||||||
persistent_persona: true
|
|
||||||
reset_between_tasks: false
|
|
||||||
lifecycle:
|
|
||||||
enabled: true
|
|
||||||
desired_state: stopped
|
|
||||||
launch:
|
|
||||||
yolo: true
|
|
||||||
`;
|
|
||||||
|
|
||||||
const agent = {
|
|
||||||
name: 'coder0',
|
|
||||||
alias: 'Coder 0',
|
|
||||||
className: 'code',
|
|
||||||
runtime: 'pi',
|
|
||||||
provider: 'openai',
|
|
||||||
model: 'gpt-5.6-sol',
|
|
||||||
reasoning: 'high',
|
|
||||||
toolPolicy: 'code',
|
|
||||||
workingDirectory: '/srv/mosaic',
|
|
||||||
persistentPersona: false,
|
|
||||||
resetBetweenTasks: true,
|
|
||||||
launch: { yolo: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
process.exitCode = undefined;
|
|
||||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fleetHome(): Promise<string> {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-command-'));
|
|
||||||
const fleetDir = join(cleanup, 'fleet');
|
|
||||||
const agentsDir = join(fleetDir, 'agents');
|
|
||||||
const rolesDir = join(fleetDir, 'roles');
|
|
||||||
await mkdir(agentsDir, { recursive: true, mode: 0o700 });
|
|
||||||
await mkdir(rolesDir, { recursive: true, mode: 0o700 });
|
|
||||||
await chmod(cleanup, 0o700);
|
|
||||||
await chmod(fleetDir, 0o700);
|
|
||||||
await chmod(agentsDir, 0o700);
|
|
||||||
await chmod(rolesDir, 0o700);
|
|
||||||
await writeFile(join(fleetDir, 'roster.yaml'), roster, { mode: 0o600 });
|
|
||||||
for (const className of ['orchestrator', 'code']) {
|
|
||||||
await writeFile(
|
|
||||||
join(rolesDir, `${className}.md`),
|
|
||||||
`# ${className}\n\n(\`class: ${className}\`)\n`,
|
|
||||||
{
|
|
||||||
mode: 0o600,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return cleanup;
|
|
||||||
}
|
|
||||||
|
|
||||||
function program(mosaicHome: string, deps: FleetCommandDeps = {}): Command {
|
|
||||||
const result = new Command();
|
|
||||||
result.exitOverride();
|
|
||||||
registerFleetCommand(result, { ...deps, mosaicHome });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function output(): { lines: string[] } {
|
|
||||||
const lines: string[] = [];
|
|
||||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
|
||||||
lines.push(value);
|
|
||||||
});
|
|
||||||
return { lines };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('mosaic fleet local roster mutations', (): void => {
|
|
||||||
it('returns non-zero when a projection failure leaves recovery work after roster persistence', (): void => {
|
|
||||||
expect(
|
|
||||||
fleetAgentMutationExitCode({
|
|
||||||
recovery: {
|
|
||||||
code: 'projection-apply-failed',
|
|
||||||
rosterPath: '/private/fleet/roster.yaml',
|
|
||||||
action: 'regenerate-projections-from-roster',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns zero for a mutation result without recovery work', (): void => {
|
|
||||||
expect(fleetAgentMutationExitCode({})).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers programmatic local create, get, update, delete, and plan commands', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const root = program(home);
|
|
||||||
const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet');
|
|
||||||
expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual(
|
|
||||||
expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs the JSON mutation commands through the direct mosaic fleet control plane', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const root = new Command();
|
|
||||||
root.exitOverride();
|
|
||||||
registerFleetCommand(root, { mosaicHome: home });
|
|
||||||
const fleet = root.commands.find((candidate: Command): boolean => candidate.name() === 'fleet');
|
|
||||||
expect(fleet?.commands.map((candidate: Command): string => candidate.name())).toEqual(
|
|
||||||
expect.arrayContaining(['create', 'delete', 'get', 'plan', 'update']),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gets one v2 roster agent as stable JSON without mutating the roster', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const captured = output();
|
|
||||||
|
|
||||||
const root = new Command();
|
|
||||||
root.exitOverride();
|
|
||||||
registerFleetCommand(root, { mosaicHome: home });
|
|
||||||
await root.parseAsync(['node', 'mosaic', 'fleet', 'get', 'orchestrator']);
|
|
||||||
|
|
||||||
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
|
|
||||||
generation: 7,
|
|
||||||
agent: { name: 'orchestrator', lifecycle: { desiredState: 'stopped' } },
|
|
||||||
});
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a JSON error and non-zero exit for a missing local agent', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const captured = output();
|
|
||||||
|
|
||||||
await program(home).parseAsync(['node', 'mosaic', 'fleet', 'get', 'missing']);
|
|
||||||
|
|
||||||
expect(JSON.parse(captured.lines.join('\n'))).toEqual({ error: { code: 'agent-not-found' } });
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prints a deterministic create plan without changing roster or generated projections', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const captured = output();
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'plan',
|
|
||||||
'create',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(agent),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
|
|
||||||
applied: false,
|
|
||||||
plan: {
|
|
||||||
operation: 'create',
|
|
||||||
currentGeneration: 7,
|
|
||||||
nextGeneration: 8,
|
|
||||||
agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
|
|
||||||
await expect(
|
|
||||||
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('plans create, update, and delete with operation-specific target names without mutation', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const captured = output();
|
|
||||||
const updatedCoder = { ...agent, alias: 'Coder Updated' };
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'plan',
|
|
||||||
'create',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(agent),
|
|
||||||
]);
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'create',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(agent),
|
|
||||||
]);
|
|
||||||
const beforePlans = await readFile(rosterPath, 'utf8');
|
|
||||||
const beforeOrchestratorProjection = await readFile(
|
|
||||||
join(home, 'fleet', 'agents', 'orchestrator.env.generated'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
captured.lines.length = 0;
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'plan',
|
|
||||||
'update',
|
|
||||||
'coder0',
|
|
||||||
'--expected-generation',
|
|
||||||
'8',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(updatedCoder),
|
|
||||||
]);
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'plan',
|
|
||||||
'delete',
|
|
||||||
'coder0',
|
|
||||||
'--expected-generation',
|
|
||||||
'8',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const plans = captured.lines.map((line: string): unknown => JSON.parse(line));
|
|
||||||
expect(plans).toMatchObject([
|
|
||||||
{ plan: { operation: 'update', agent: { name: 'coder0' } } },
|
|
||||||
{ plan: { operation: 'delete', agent: { name: 'coder0' } } },
|
|
||||||
]);
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toBe(beforePlans);
|
|
||||||
expect(
|
|
||||||
await readFile(join(home, 'fleet', 'agents', 'orchestrator.env.generated'), 'utf8'),
|
|
||||||
).toBe(beforeOrchestratorProjection);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['plan create', ['fleet', 'plan', 'create'], 'top-level'],
|
|
||||||
['create', ['fleet', 'create'], 'top-level'],
|
|
||||||
['update', ['fleet', 'update', 'orchestrator'], 'top-level'],
|
|
||||||
['plan create', ['fleet', 'plan', 'create'], 'launch'],
|
|
||||||
['create', ['fleet', 'create'], 'launch'],
|
|
||||||
['update', ['fleet', 'update', 'orchestrator'], 'launch'],
|
|
||||||
])(
|
|
||||||
'rejects unknown and prototype-sensitive agent fields without mutation',
|
|
||||||
async (_operation: string, command: string[], shape: string): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const agentsDir = join(home, 'fleet', 'agents');
|
|
||||||
const captured = output();
|
|
||||||
const artifactContents = new Map([
|
|
||||||
['orchestrator.env.generated', 'generated-before\n'],
|
|
||||||
['orchestrator.env.local', 'local-before\n'],
|
|
||||||
['orchestrator.env', 'legacy-before\n'],
|
|
||||||
['orchestrator.env.quarantine', 'quarantine-before\n'],
|
|
||||||
]);
|
|
||||||
for (const [filename, content] of artifactContents) {
|
|
||||||
await writeFile(join(agentsDir, filename), content, { mode: 0o600 });
|
|
||||||
}
|
|
||||||
const rejectedValue = 'must-not-appear-in-diagnostics';
|
|
||||||
const unsafeAgent = {
|
|
||||||
...agent,
|
|
||||||
name: 'orchestrator',
|
|
||||||
launch: shape === 'launch' ? { yolo: true, command: rejectedValue } : { yolo: true },
|
|
||||||
...(shape === 'top-level'
|
|
||||||
? {
|
|
||||||
command: rejectedValue,
|
|
||||||
channel: rejectedValue,
|
|
||||||
secretRef: rejectedValue,
|
|
||||||
constructor: rejectedValue,
|
|
||||||
prototype: rejectedValue,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
const payload =
|
|
||||||
shape === 'top-level'
|
|
||||||
? JSON.stringify(unsafeAgent).replace(/}$/, ',"__proto__":{"unsupported":true}}')
|
|
||||||
: JSON.stringify(unsafeAgent);
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
...command,
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
'--agent',
|
|
||||||
payload,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const diagnostic = captured.lines.pop() ?? '';
|
|
||||||
expect(JSON.parse(diagnostic)).toEqual({ error: { code: 'invalid-request' } });
|
|
||||||
expect(diagnostic).not.toContain(rejectedValue);
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toBe(roster);
|
|
||||||
for (const [filename, content] of artifactContents) {
|
|
||||||
expect(await readFile(join(agentsDir, filename), 'utf8')).toBe(content);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('rejects missing update/delete plan names and an extra create plan name', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const captured = output();
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'plan',
|
|
||||||
'delete',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
]);
|
|
||||||
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } });
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'plan',
|
|
||||||
'create',
|
|
||||||
'coder0',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(agent),
|
|
||||||
]);
|
|
||||||
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({ error: { code: 'invalid-request' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('plans persisted start as desired state without starting a runtime', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const captured = output();
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'plan',
|
|
||||||
'create',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(agent),
|
|
||||||
'--persisted-start',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
|
|
||||||
applied: false,
|
|
||||||
plan: { agent: { lifecycle: { desiredState: 'running' } } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns non-zero JSON when a filesystem projection fails after roster persistence', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const agentsDir = join(home, 'fleet', 'agents');
|
|
||||||
const captured = output();
|
|
||||||
|
|
||||||
await program(home, {
|
|
||||||
projectionApplier: async (): Promise<never> => {
|
|
||||||
throw new Error('injected projection failure');
|
|
||||||
},
|
|
||||||
}).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'create',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(agent),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(JSON.parse(captured.lines.join('\n'))).toMatchObject({
|
|
||||||
applied: false,
|
|
||||||
authoritativeRoster: 'committed',
|
|
||||||
projections: 'incomplete',
|
|
||||||
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
|
||||||
});
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 8');
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toContain('name: coder0');
|
|
||||||
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes only the target generated projection while retaining legacy and quarantine artifacts', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const agentsDir = join(home, 'fleet', 'agents');
|
|
||||||
const captured = output();
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'create',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(agent),
|
|
||||||
]);
|
|
||||||
await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
await writeFile(join(agentsDir, 'coder0.env'), 'MOSAIC_AGENT_COMMAND=legacy-command\n', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
await writeFile(join(agentsDir, 'coder0.env.quarantine'), 'quarantine-before\n', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
await writeFile(join(agentsDir, 'unrelated.env.generated'), 'unrelated-before\n', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
const beforeDryRun = await Promise.all([
|
|
||||||
readFile(rosterPath, 'utf8'),
|
|
||||||
readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'),
|
|
||||||
readFile(join(agentsDir, 'coder0.env.local'), 'utf8'),
|
|
||||||
readFile(join(agentsDir, 'coder0.env'), 'utf8'),
|
|
||||||
readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'),
|
|
||||||
readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'delete',
|
|
||||||
'coder0',
|
|
||||||
'--expected-generation',
|
|
||||||
'8',
|
|
||||||
'--dry-run',
|
|
||||||
]);
|
|
||||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
|
|
||||||
applied: false,
|
|
||||||
authoritativeRoster: 'unchanged',
|
|
||||||
projections: 'not-applied',
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
await Promise.all([
|
|
||||||
readFile(rosterPath, 'utf8'),
|
|
||||||
readFile(join(agentsDir, 'coder0.env.generated'), 'utf8'),
|
|
||||||
readFile(join(agentsDir, 'coder0.env.local'), 'utf8'),
|
|
||||||
readFile(join(agentsDir, 'coder0.env'), 'utf8'),
|
|
||||||
readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8'),
|
|
||||||
readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8'),
|
|
||||||
]),
|
|
||||||
).toEqual(beforeDryRun);
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'delete',
|
|
||||||
'coder0',
|
|
||||||
'--expected-generation',
|
|
||||||
'8',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
|
|
||||||
applied: true,
|
|
||||||
authoritativeRoster: 'committed',
|
|
||||||
projections: 'complete',
|
|
||||||
});
|
|
||||||
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
|
|
||||||
expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(beforeDryRun[2]);
|
|
||||||
expect(await readFile(join(agentsDir, 'coder0.env'), 'utf8')).toBe(beforeDryRun[3]);
|
|
||||||
expect(await readFile(join(agentsDir, 'coder0.env.quarantine'), 'utf8')).toBe(beforeDryRun[4]);
|
|
||||||
expect(await readFile(join(agentsDir, 'unrelated.env.generated'), 'utf8')).toBe(
|
|
||||||
beforeDryRun[5],
|
|
||||||
);
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports truthful partial recovery when a delete projection fails after roster persistence', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const agentsDir = join(home, 'fleet', 'agents');
|
|
||||||
const captured = output();
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'create',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(agent),
|
|
||||||
]);
|
|
||||||
|
|
||||||
captured.lines.length = 0;
|
|
||||||
await program(home, {
|
|
||||||
projectionApplier: async (): Promise<never> => {
|
|
||||||
throw new Error('injected projection failure');
|
|
||||||
},
|
|
||||||
}).parseAsync(['node', 'mosaic', 'fleet', 'delete', 'coder0', '--expected-generation', '8']);
|
|
||||||
|
|
||||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
|
|
||||||
applied: false,
|
|
||||||
authoritativeRoster: 'committed',
|
|
||||||
projections: 'incomplete',
|
|
||||||
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
|
||||||
});
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 9');
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0');
|
|
||||||
expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain(
|
|
||||||
'MOSAIC_AGENT_NAME=coder0',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['unsafe directory permissions', 'symlinked directory'] as const)(
|
|
||||||
'rejects delete before roster mutation for %s',
|
|
||||||
async (hazard: 'unsafe directory permissions' | 'symlinked directory'): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const agentsDir = join(home, 'fleet', 'agents');
|
|
||||||
const captured = output();
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'create',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(agent),
|
|
||||||
]);
|
|
||||||
const beforeRoster = await readFile(rosterPath, 'utf8');
|
|
||||||
let generatedPath = join(agentsDir, 'coder0.env.generated');
|
|
||||||
|
|
||||||
if (hazard === 'unsafe directory permissions') {
|
|
||||||
await chmod(agentsDir, 0o777);
|
|
||||||
} else {
|
|
||||||
const targetDir = join(home, 'fleet', 'agents-target');
|
|
||||||
await rename(agentsDir, targetDir);
|
|
||||||
await symlink(targetDir, agentsDir, 'dir');
|
|
||||||
generatedPath = join(targetDir, 'coder0.env.generated');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
captured.lines.length = 0;
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'delete',
|
|
||||||
'coder0',
|
|
||||||
'--expected-generation',
|
|
||||||
'8',
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if (hazard === 'unsafe directory permissions') await chmod(agentsDir, 0o700);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(JSON.parse(captured.lines.pop() ?? '')).toEqual({
|
|
||||||
error: { code: 'mutation-failed' },
|
|
||||||
});
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toBe(beforeRoster);
|
|
||||||
expect(await readFile(generatedPath, 'utf8')).toContain('MOSAIC_AGENT_NAME=coder0');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('creates, updates, and deletes through the v2 roster without runtime actions', async (): Promise<void> => {
|
|
||||||
const home = await fleetHome();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const agentsDir = join(home, 'fleet', 'agents');
|
|
||||||
const captured = output();
|
|
||||||
const updated = { ...agent, alias: 'Coder Zero' };
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'create',
|
|
||||||
'--expected-generation',
|
|
||||||
'7',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(agent),
|
|
||||||
]);
|
|
||||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({ applied: true });
|
|
||||||
expect(await readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).toContain(
|
|
||||||
'MOSAIC_AGENT_NAME=coder0',
|
|
||||||
);
|
|
||||||
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'update',
|
|
||||||
'coder0',
|
|
||||||
'--expected-generation',
|
|
||||||
'8',
|
|
||||||
'--agent',
|
|
||||||
JSON.stringify(updated),
|
|
||||||
]);
|
|
||||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
|
|
||||||
applied: true,
|
|
||||||
plan: { nextGeneration: 9, agent: { alias: 'Coder Zero' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
await writeFile(join(agentsDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
await program(home).parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'delete',
|
|
||||||
'coder0',
|
|
||||||
'--expected-generation',
|
|
||||||
'9',
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(JSON.parse(captured.lines.pop() ?? '')).toMatchObject({
|
|
||||||
applied: true,
|
|
||||||
plan: { operation: 'delete', nextGeneration: 10, agent: { name: 'coder0' } },
|
|
||||||
});
|
|
||||||
await expect(readFile(join(agentsDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
|
|
||||||
expect(await readFile(join(agentsDir, 'coder0.env.local'), 'utf8')).toBe(
|
|
||||||
'MOSAIC_RUNTIME_BIN=/usr/bin/retain\n',
|
|
||||||
);
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toContain('generation: 10');
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).not.toContain('name: coder0');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import type { Command } from 'commander';
|
|
||||||
import {
|
|
||||||
executeFleetAgentMutation,
|
|
||||||
FleetAgentMutationError,
|
|
||||||
type FleetAgentMutationAgent,
|
|
||||||
type FleetAgentMutationOperation,
|
|
||||||
type FleetAgentMutationOptions,
|
|
||||||
type FleetAgentMutationRequest,
|
|
||||||
type FleetAgentMutationResult,
|
|
||||||
} from '../fleet/fleet-agent-crud.js';
|
|
||||||
import {
|
|
||||||
parseRosterV2,
|
|
||||||
ROSTER_V2_REASONING_LEVELS,
|
|
||||||
ROSTER_V2_SUPPORTED_RUNTIMES,
|
|
||||||
type FleetRosterV2,
|
|
||||||
type RosterV2ReasoningLevel,
|
|
||||||
type RosterV2RuntimeName,
|
|
||||||
} from '../fleet/roster-v2.js';
|
|
||||||
|
|
||||||
export interface FleetAgentCrudCommandDeps {
|
|
||||||
readonly mosaicHome?: string;
|
|
||||||
/** Test seam for deterministic post-roster filesystem projection failures. */
|
|
||||||
readonly projectionApplier?: FleetAgentMutationOptions['projectionApplier'];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MutationOptions {
|
|
||||||
readonly expectedGeneration: string;
|
|
||||||
readonly agent?: string;
|
|
||||||
readonly dryRun?: boolean;
|
|
||||||
readonly persistedStart?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AGENT_REQUEST_KEYS = new Set([
|
|
||||||
'name',
|
|
||||||
'alias',
|
|
||||||
'className',
|
|
||||||
'runtime',
|
|
||||||
'provider',
|
|
||||||
'model',
|
|
||||||
'reasoning',
|
|
||||||
'toolPolicy',
|
|
||||||
'workingDirectory',
|
|
||||||
'persistentPersona',
|
|
||||||
'resetBetweenTasks',
|
|
||||||
'launch',
|
|
||||||
]);
|
|
||||||
const AGENT_LAUNCH_REQUEST_KEYS = new Set(['yolo']);
|
|
||||||
|
|
||||||
/** Registers only roster-v2 desired-state mutations; it never invokes runtime actions. */
|
|
||||||
export function registerFleetAgentCrudCommands(
|
|
||||||
agentCommand: Command,
|
|
||||||
deps: FleetAgentCrudCommandDeps = {},
|
|
||||||
): void {
|
|
||||||
agentCommand
|
|
||||||
.command('get <name>')
|
|
||||||
.description('Read one local roster-v2 agent as JSON')
|
|
||||||
.action(async (name: string): Promise<void> => {
|
|
||||||
await writeJsonOutcome(async (): Promise<void> => {
|
|
||||||
const roster = await loadRoster(agentCommand, deps);
|
|
||||||
const agent = roster.agents.find((candidate): boolean => candidate.name === name);
|
|
||||||
if (!agent)
|
|
||||||
throw new FleetAgentMutationError('agent-not-found', `Agent "${name}" is not in roster.`);
|
|
||||||
printJson({ generation: roster.generation, agent });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
registerMutationCommand(agentCommand, deps, 'create');
|
|
||||||
registerMutationCommand(agentCommand, deps, 'update');
|
|
||||||
registerMutationCommand(agentCommand, deps, 'delete');
|
|
||||||
|
|
||||||
agentCommand
|
|
||||||
.command('plan <operation> [name]')
|
|
||||||
.description('Plan a local roster-v2 mutation without writing roster or projections')
|
|
||||||
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
|
|
||||||
.option('--agent <json>', 'JSON agent payload for create or update')
|
|
||||||
.option(
|
|
||||||
'--persisted-start',
|
|
||||||
'Plan create with desired_state: running without starting a runtime',
|
|
||||||
)
|
|
||||||
.action(
|
|
||||||
async (operation: string, name: string | undefined, opts: MutationOptions): Promise<void> => {
|
|
||||||
await writeJsonOutcome(async (): Promise<void> => {
|
|
||||||
const parsedOperation = parseOperation(operation);
|
|
||||||
await executeCommand(
|
|
||||||
agentCommand,
|
|
||||||
deps,
|
|
||||||
parsedOperation,
|
|
||||||
opts,
|
|
||||||
true,
|
|
||||||
parsePlanTargetName(parsedOperation, name),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerMutationCommand(
|
|
||||||
agentCommand: Command,
|
|
||||||
deps: FleetAgentCrudCommandDeps,
|
|
||||||
operation: FleetAgentMutationOperation,
|
|
||||||
): void {
|
|
||||||
const command = agentCommand
|
|
||||||
.command(
|
|
||||||
operation === 'update'
|
|
||||||
? 'update <name>'
|
|
||||||
: `${operation}${operation === 'delete' ? ' <name>' : ''}`,
|
|
||||||
)
|
|
||||||
.description(`${operation} a local roster-v2 agent without runtime actions`)
|
|
||||||
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
|
|
||||||
.option('--agent <json>', 'JSON agent payload for create or update')
|
|
||||||
.option('--dry-run', 'Validate and plan without writing roster or projections');
|
|
||||||
|
|
||||||
if (operation === 'create') {
|
|
||||||
command.option(
|
|
||||||
'--persisted-start',
|
|
||||||
'Persist desired_state: running without starting a runtime',
|
|
||||||
);
|
|
||||||
command.action(async (opts: MutationOptions): Promise<void> => {
|
|
||||||
await writeJsonOutcome(async (): Promise<void> => {
|
|
||||||
await executeCommand(agentCommand, deps, operation, opts, false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
command.action(async (name: string, opts: MutationOptions): Promise<void> => {
|
|
||||||
await writeJsonOutcome(async (): Promise<void> => {
|
|
||||||
await executeCommand(agentCommand, deps, operation, opts, false, name);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeCommand(
|
|
||||||
agentCommand: Command,
|
|
||||||
deps: FleetAgentCrudCommandDeps,
|
|
||||||
operation: FleetAgentMutationOperation,
|
|
||||||
opts: MutationOptions,
|
|
||||||
forceDryRun: boolean,
|
|
||||||
name?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const mosaicHome = resolveMosaicHome(agentCommand, deps);
|
|
||||||
const rosterPath = resolveRosterPath(agentCommand, mosaicHome);
|
|
||||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
|
||||||
const request = buildRequest(operation, opts, name);
|
|
||||||
const result = await executeFleetAgentMutation({
|
|
||||||
roster,
|
|
||||||
request,
|
|
||||||
mosaicHome,
|
|
||||||
rosterPath,
|
|
||||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
|
||||||
rolesDir: join(mosaicHome, 'fleet', 'roles'),
|
|
||||||
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
|
|
||||||
dryRun: forceDryRun || opts.dryRun === true,
|
|
||||||
...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }),
|
|
||||||
});
|
|
||||||
printJson(result);
|
|
||||||
process.exitCode = fleetAgentMutationExitCode(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A persisted roster with failed derived projections is a non-zero CLI outcome. */
|
|
||||||
export function fleetAgentMutationExitCode(
|
|
||||||
result: Pick<FleetAgentMutationResult, 'recovery'>,
|
|
||||||
): 0 | 1 {
|
|
||||||
return result.recovery === undefined ? 0 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePlanTargetName(
|
|
||||||
operation: FleetAgentMutationOperation,
|
|
||||||
name: string | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
if (operation === 'create') {
|
|
||||||
if (name !== undefined) {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'invalid-request',
|
|
||||||
'Create plan does not accept a target name.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (name === undefined) {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'invalid-request',
|
|
||||||
`${operation} plan requires a target agent name.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequest(
|
|
||||||
operation: FleetAgentMutationOperation,
|
|
||||||
opts: MutationOptions,
|
|
||||||
name?: string,
|
|
||||||
): FleetAgentMutationRequest {
|
|
||||||
const expectedGeneration = parseExpectedGeneration(opts.expectedGeneration);
|
|
||||||
const agent = opts.agent === undefined ? undefined : parseAgent(opts.agent);
|
|
||||||
if ((operation === 'create' || operation === 'update') && !agent) {
|
|
||||||
throw new FleetAgentMutationError('invalid-request', `${operation} requires --agent JSON.`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
operation,
|
|
||||||
expectedGeneration,
|
|
||||||
...(name === undefined ? {} : { name }),
|
|
||||||
...(agent === undefined ? {} : { agent }),
|
|
||||||
...(operation === 'create' && opts.persistedStart === true ? { persistedStart: true } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseExpectedGeneration(value: string): number {
|
|
||||||
const generation = Number(value);
|
|
||||||
if (!Number.isSafeInteger(generation) || generation < 1) {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'invalid-request',
|
|
||||||
'--expected-generation must be a positive safe integer.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return generation;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOperation(value: string): FleetAgentMutationOperation {
|
|
||||||
if (value === 'create' || value === 'update' || value === 'delete') return value;
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'invalid-request',
|
|
||||||
'plan operation must be create, update, or delete.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAgent(source: string): FleetAgentMutationAgent {
|
|
||||||
let value: unknown;
|
|
||||||
try {
|
|
||||||
value = JSON.parse(source);
|
|
||||||
} catch {
|
|
||||||
throw new FleetAgentMutationError('invalid-request', '--agent must be valid JSON.');
|
|
||||||
}
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
throw new FleetAgentMutationError('invalid-request', '--agent must be a JSON object.');
|
|
||||||
}
|
|
||||||
rejectUnknownKeys(value, AGENT_REQUEST_KEYS, '--agent');
|
|
||||||
|
|
||||||
const runtime = requiredRuntime(value, 'runtime');
|
|
||||||
const reasoning = requiredReasoning(value, 'reasoning');
|
|
||||||
const launch = requiredRecord(value, 'launch');
|
|
||||||
rejectUnknownKeys(launch, AGENT_LAUNCH_REQUEST_KEYS, '--agent.launch');
|
|
||||||
return {
|
|
||||||
name: requiredString(value, 'name'),
|
|
||||||
alias: requiredString(value, 'alias'),
|
|
||||||
className: requiredString(value, 'className'),
|
|
||||||
runtime,
|
|
||||||
provider: requiredString(value, 'provider'),
|
|
||||||
model: requiredString(value, 'model'),
|
|
||||||
reasoning,
|
|
||||||
toolPolicy: requiredString(value, 'toolPolicy'),
|
|
||||||
workingDirectory: requiredString(value, 'workingDirectory'),
|
|
||||||
persistentPersona: requiredBoolean(value, 'persistentPersona'),
|
|
||||||
resetBetweenTasks: requiredBoolean(value, 'resetBetweenTasks'),
|
|
||||||
launch: { yolo: requiredBoolean(launch, 'yolo') },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRoster(
|
|
||||||
agentCommand: Command,
|
|
||||||
deps: FleetAgentCrudCommandDeps,
|
|
||||||
): Promise<FleetRosterV2> {
|
|
||||||
const mosaicHome = resolveMosaicHome(agentCommand, deps);
|
|
||||||
const rosterPath = resolveRosterPath(agentCommand, mosaicHome);
|
|
||||||
return parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMosaicHome(agentCommand: Command, deps: FleetAgentCrudCommandDeps): string {
|
|
||||||
const opts = agentCommand.optsWithGlobals<{ mosaicHome?: string }>();
|
|
||||||
return opts.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRosterPath(agentCommand: Command, mosaicHome: string): string {
|
|
||||||
const opts = agentCommand.optsWithGlobals<{ roster?: string }>();
|
|
||||||
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
|
|
||||||
if (opts.roster !== undefined && resolve(opts.roster) !== resolve(canonical)) {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'invalid-request',
|
|
||||||
'Roster-v2 mutations require the canonical <mosaic-home>/fleet/roster.yaml path.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return canonical;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rejectUnknownKeys(
|
|
||||||
value: Record<string, unknown>,
|
|
||||||
allowedKeys: ReadonlySet<string>,
|
|
||||||
field: string,
|
|
||||||
): void {
|
|
||||||
const hasUnsupportedOwnProperty = Object.getOwnPropertyNames(value).some(
|
|
||||||
(key: string): boolean => !allowedKeys.has(key),
|
|
||||||
);
|
|
||||||
if (hasUnsupportedOwnProperty || Object.getOwnPropertySymbols(value).length > 0) {
|
|
||||||
throw new FleetAgentMutationError('invalid-request', `${field} contains unsupported keys.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredString(value: Record<string, unknown>, key: string): string {
|
|
||||||
const candidate = requiredOwnValue(value, key);
|
|
||||||
if (typeof candidate !== 'string' || candidate.length === 0) {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'invalid-request',
|
|
||||||
`--agent.${key} must be a non-empty string.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredBoolean(value: Record<string, unknown>, key: string): boolean {
|
|
||||||
const candidate = requiredOwnValue(value, key);
|
|
||||||
if (typeof candidate !== 'boolean') {
|
|
||||||
throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be a boolean.`);
|
|
||||||
}
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredRecord(value: Record<string, unknown>, key: string): Record<string, unknown> {
|
|
||||||
const candidate = requiredOwnValue(value, key);
|
|
||||||
if (!isRecord(candidate)) {
|
|
||||||
throw new FleetAgentMutationError('invalid-request', `--agent.${key} must be an object.`);
|
|
||||||
}
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredRuntime(value: Record<string, unknown>, key: string): RosterV2RuntimeName {
|
|
||||||
const candidate = requiredString(value, key);
|
|
||||||
if (!isRuntime(candidate)) {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'invalid-request',
|
|
||||||
`--agent.${key} is not a supported runtime.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredReasoning(value: Record<string, unknown>, key: string): RosterV2ReasoningLevel {
|
|
||||||
const candidate = requiredString(value, key);
|
|
||||||
if (!isReasoning(candidate)) {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'invalid-request',
|
|
||||||
`--agent.${key} is not a supported reasoning level.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRuntime(value: string): value is RosterV2RuntimeName {
|
|
||||||
return ROSTER_V2_SUPPORTED_RUNTIMES.some(
|
|
||||||
(runtime: RosterV2RuntimeName): boolean => runtime === value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReasoning(value: string): value is RosterV2ReasoningLevel {
|
|
||||||
return ROSTER_V2_REASONING_LEVELS.some(
|
|
||||||
(reasoning: RosterV2ReasoningLevel): boolean => reasoning === value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredOwnValue(value: Record<string, unknown>, key: string): unknown {
|
|
||||||
if (!Object.hasOwn(value, key)) {
|
|
||||||
throw new FleetAgentMutationError('invalid-request', `--agent.${key} is required.`);
|
|
||||||
}
|
|
||||||
return value[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
Object.getPrototypeOf(value) === Object.prototype
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeJsonOutcome(action: () => Promise<void>): Promise<void> {
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
process.exitCode = 1;
|
|
||||||
printJson({
|
|
||||||
error: {
|
|
||||||
code: error instanceof FleetAgentMutationError ? error.code : 'mutation-failed',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function printJson(value: object): void {
|
|
||||||
console.log(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,13 @@
|
|||||||
import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join, relative, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
authorityForCanonicalClass,
|
|
||||||
canonicalizeRoleClass,
|
|
||||||
extractClassesFromDir,
|
extractClassesFromDir,
|
||||||
extractClassesFromDirSync,
|
|
||||||
listPersonaClasses,
|
listPersonaClasses,
|
||||||
personaStatus,
|
personaStatus,
|
||||||
resolvePersona,
|
resolvePersona,
|
||||||
resolvePersonaFrom,
|
|
||||||
resolvePersonaSync,
|
|
||||||
} from './fleet-personas.js';
|
} from './fleet-personas.js';
|
||||||
import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js';
|
import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js';
|
||||||
|
|
||||||
@@ -59,156 +54,13 @@ afterEach(async () => {
|
|||||||
await rm(tmp, { recursive: true, force: true });
|
await rm(tmp, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('FCM class canonicalization and authority', () => {
|
|
||||||
it.each([
|
|
||||||
['implementer', 'code'],
|
|
||||||
['reviewer', 'review'],
|
|
||||||
['operator-interaction', 'interaction'],
|
|
||||||
])('canonicalizes the approved alias %s to %s', (requested: string, canonical: string) => {
|
|
||||||
expect(canonicalizeRoleClass(requested)).toEqual({
|
|
||||||
requestedClass: requested,
|
|
||||||
canonicalClass: canonical,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['worker', 'analyst', 'canary', 'tess', 'ultron', 'constructor'])(
|
|
||||||
'does not infer an alias for %s',
|
|
||||||
(requested: string) => {
|
|
||||||
expect(canonicalizeRoleClass(requested)).toEqual({
|
|
||||||
requestedClass: requested,
|
|
||||||
canonicalClass: requested,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('resolves inherited-property class names without granting protected authority', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
join(overrideDir, 'constructor.md'),
|
|
||||||
overridePersona('constructor', 'custom'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolved = await resolvePersona('constructor', { rolesDir, overrideDir });
|
|
||||||
expect(resolved).toMatchObject({
|
|
||||||
requestedClass: 'constructor',
|
|
||||||
canonicalClass: 'constructor',
|
|
||||||
klass: 'constructor',
|
|
||||||
layer: 'override',
|
|
||||||
});
|
|
||||||
expect(authorityForCanonicalClass('constructor')).toMatchObject({
|
|
||||||
mayMerge: false,
|
|
||||||
mayIssueValidationCertificate: false,
|
|
||||||
mayOrchestrate: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('canonicalizes before override lookup and lets the canonical override win', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
join(overrideDir, 'implementer.md'),
|
|
||||||
overridePersona('implementer', 'legacy'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
await writeFile(
|
|
||||||
join(overrideDir, 'code.md'),
|
|
||||||
overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolved = await resolvePersona('implementer', { rolesDir, overrideDir });
|
|
||||||
expect(resolved).toMatchObject({
|
|
||||||
requestedClass: 'implementer',
|
|
||||||
canonicalClass: 'code',
|
|
||||||
klass: 'code',
|
|
||||||
layer: 'override',
|
|
||||||
});
|
|
||||||
expect(resolved?.content).toContain('CANONICAL-OVERRIDE');
|
|
||||||
expect(resolved?.content).not.toContain('legacy');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps sync and async resolution equivalent for aliases and canonical overrides', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
join(overrideDir, 'code.md'),
|
|
||||||
overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const asyncResolution = await resolvePersona('implementer', { rolesDir, overrideDir });
|
|
||||||
const syncResolution = resolvePersonaSync('implementer', { rolesDir, overrideDir });
|
|
||||||
expect(syncResolution).toEqual(asyncResolution);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives immutable protected authority only from the canonical class', () => {
|
|
||||||
expect(authorityForCanonicalClass('merge-gate')).toMatchObject({ mayMerge: true });
|
|
||||||
for (const klass of [
|
|
||||||
'validator',
|
|
||||||
'orchestrator',
|
|
||||||
'team-leader',
|
|
||||||
'interaction',
|
|
||||||
'code',
|
|
||||||
'custom-role',
|
|
||||||
]) {
|
|
||||||
expect(authorityForCanonicalClass(klass).mayMerge).toBe(false);
|
|
||||||
}
|
|
||||||
expect(authorityForCanonicalClass('validator')).toMatchObject({
|
|
||||||
mayIssueValidationCertificate: true,
|
|
||||||
mayMerge: false,
|
|
||||||
});
|
|
||||||
expect(authorityForCanonicalClass('orchestrator')).toMatchObject({
|
|
||||||
mayOrchestrate: true,
|
|
||||||
mayIssueLeases: true,
|
|
||||||
mayMerge: false,
|
|
||||||
});
|
|
||||||
expect(authorityForCanonicalClass('team-leader')).toMatchObject({
|
|
||||||
leasedCapacityOnly: true,
|
|
||||||
mayMutateRoster: false,
|
|
||||||
mayAccessCredentials: false,
|
|
||||||
mayMerge: false,
|
|
||||||
});
|
|
||||||
expect(authorityForCanonicalClass('interaction')).toMatchObject({
|
|
||||||
requestStatusOnly: true,
|
|
||||||
mayOrchestrate: false,
|
|
||||||
mayMerge: false,
|
|
||||||
});
|
|
||||||
expect(Object.isFrozen(authorityForCanonicalClass('merge-gate'))).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('required FCM role library', () => {
|
|
||||||
it.each([
|
|
||||||
'code',
|
|
||||||
'review',
|
|
||||||
'validator',
|
|
||||||
'orchestrator',
|
|
||||||
'team-leader',
|
|
||||||
'enhancer',
|
|
||||||
'interaction',
|
|
||||||
'merge-gate',
|
|
||||||
])('resolves %s through the real framework role library', async (klass: string) => {
|
|
||||||
const resolved = await resolvePersona(klass, {
|
|
||||||
rolesDir: realRolesDir,
|
|
||||||
overrideDir: join(tmp, 'none'),
|
|
||||||
});
|
|
||||||
expect(resolved).not.toBeNull();
|
|
||||||
expect(resolved?.canonicalClass).toBe(klass);
|
|
||||||
expect(resolved?.content.trim()).not.toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('extractClassesFromDir (shared extraction)', () => {
|
describe('extractClassesFromDir (shared extraction)', () => {
|
||||||
it('records class + domain and distinguishes scanned from missing directories', async () => {
|
it('records class + domain from inline markers and degrades on missing dir', async () => {
|
||||||
const base = await extractClassesFromDir(rolesDir);
|
const base = await extractClassesFromDir(rolesDir);
|
||||||
expect(base.scanState).toBe('scanned');
|
|
||||||
expect(base.classes.has('ceo')).toBe(true);
|
expect(base.classes.has('ceo')).toBe(true);
|
||||||
expect(base.byClass.get('ceo')?.domain).toBe('executive');
|
expect(base.byClass.get('ceo')?.domain).toBe('executive');
|
||||||
|
const missing = await extractClassesFromDir(join(tmp, 'nope'));
|
||||||
const missingDir = join(tmp, 'nope');
|
|
||||||
const missing = await extractClassesFromDir(missingDir);
|
|
||||||
expect(missing.scanState).toBe('missing');
|
|
||||||
expect(missing.classes.size).toBe(0);
|
expect(missing.classes.size).toBe(0);
|
||||||
expect(extractClassesFromDirSync(missingDir).scanState).toBe('missing');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,287 +81,6 @@ describe('resolvePersona — override wins', () => {
|
|||||||
expect(resolved?.content).toContain('BASELINE');
|
expect(resolved?.content).toContain('BASELINE');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not let a LIBRARY-only row shadow a readable baseline persona', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
join(overrideDir, 'LIBRARY.md'),
|
|
||||||
'| Persona | Purpose |\n| --- | --- |\n| code | Index only |\n',
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolved = await resolvePersona('code', { rolesDir, overrideDir });
|
|
||||||
expect(resolved?.layer).toBe('baseline');
|
|
||||||
expect(resolved?.content).toContain('BASELINE');
|
|
||||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
|
|
||||||
expect(
|
|
||||||
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
|
|
||||||
?.status,
|
|
||||||
).toBe('baseline');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not let an incidental later class marker shadow a readable baseline persona', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
join(overrideDir, 'mascot.md'),
|
|
||||||
'# mascot\n\n(`class: mascot`)\n\nSee also (`class: code`).\n',
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolved = await resolvePersona('code', { rolesDir, overrideDir });
|
|
||||||
expect(resolved?.layer).toBe('baseline');
|
|
||||||
expect(resolved?.content).toContain('BASELINE');
|
|
||||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
|
|
||||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('mascot')).toBe(true);
|
|
||||||
expect(
|
|
||||||
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
|
|
||||||
?.status,
|
|
||||||
).toBe('baseline');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not advertise an incidental-only class through listing or status APIs', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
join(overrideDir, 'mascot.md'),
|
|
||||||
'# mascot\n\n(`class: mascot`)\n\nSee also (`class: phantom`).\n',
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('phantom')).toBe(false);
|
|
||||||
expect(
|
|
||||||
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'phantom'),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a canonical filename whose explicit marker names another class', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await writeFile(join(overrideDir, 'merge-gate.md'), overridePersona('mascot', 'fun'), 'utf8');
|
|
||||||
await writeFile(
|
|
||||||
join(rolesDir, 'merge-gate.md'),
|
|
||||||
baselinePersona('merge-gate', 'governance'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await resolvePersona('merge-gate', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
expect(resolvePersonaSync('merge-gate', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('merge-gate')).toBe(false);
|
|
||||||
expect(
|
|
||||||
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'merge-gate'),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed if a marker-defined override becomes unreadable after extraction', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
const overrideFile = join(overrideDir, 'engineering.md');
|
|
||||||
await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8');
|
|
||||||
const [base, over] = await Promise.all([
|
|
||||||
extractClassesFromDir(rolesDir),
|
|
||||||
extractClassesFromDir(overrideDir),
|
|
||||||
]);
|
|
||||||
await rm(overrideFile);
|
|
||||||
await mkdir(overrideFile);
|
|
||||||
|
|
||||||
expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed if a marker-defined override changes identity after extraction', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
const overrideFile = join(overrideDir, 'engineering.md');
|
|
||||||
await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8');
|
|
||||||
const [base, over] = await Promise.all([
|
|
||||||
extractClassesFromDir(rolesDir),
|
|
||||||
extractClassesFromDir(overrideDir),
|
|
||||||
]);
|
|
||||||
await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8');
|
|
||||||
|
|
||||||
expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull();
|
|
||||||
|
|
||||||
const classes = await listPersonaClasses({ rolesDir, overrideDir });
|
|
||||||
expect(classes.has('code')).toBe(true);
|
|
||||||
expect(classes.has('mascot')).toBe(true);
|
|
||||||
const status = new Map(
|
|
||||||
(await personaStatus({ rolesDir, overrideDir })).map((entry) => [entry.klass, entry]),
|
|
||||||
);
|
|
||||||
expect(status.get('code')?.status).toBe('baseline');
|
|
||||||
expect(status.get('mascot')?.status).toBe('custom');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed if a markerless cached override gains a conflicting marker', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
const overrideFile = join(overrideDir, 'merge-gate.md');
|
|
||||||
await writeFile(overrideFile, '# markerless merge gate\n', 'utf8');
|
|
||||||
await writeFile(
|
|
||||||
join(rolesDir, 'merge-gate.md'),
|
|
||||||
baselinePersona('merge-gate', 'governance'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
const [base, over] = await Promise.all([
|
|
||||||
extractClassesFromDir(rolesDir),
|
|
||||||
extractClassesFromDir(overrideDir),
|
|
||||||
]);
|
|
||||||
await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8');
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await resolvePersonaFrom('merge-gate', { rolesDir, overrideDir, base, over }),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed asynchronously when the override directory cannot be scanned', async () => {
|
|
||||||
await writeFile(overrideDir, 'not a directory', 'utf8');
|
|
||||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed synchronously when the override directory cannot be scanned', async () => {
|
|
||||||
await writeFile(overrideDir, 'not a directory', 'utf8');
|
|
||||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed asynchronously and synchronously for a dangling override-directory symlink', async () => {
|
|
||||||
await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir');
|
|
||||||
|
|
||||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed asynchronously and synchronously when an override ancestor is a dangling symlink', async () => {
|
|
||||||
const danglingAncestor = join(tmp, 'dangling-ancestor');
|
|
||||||
await symlink(join(tmp, 'missing-ancestor-target'), danglingAncestor, 'dir');
|
|
||||||
overrideDir = join(danglingAncestor, 'nested', 'roles.local');
|
|
||||||
|
|
||||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
|
|
||||||
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when dot-dot traversal crosses a dangling override ancestor', async () => {
|
|
||||||
const danglingAncestor = join(tmp, 'dangling-dotdot-ancestor');
|
|
||||||
await symlink(join(tmp, 'missing-dotdot-target'), danglingAncestor, 'dir');
|
|
||||||
overrideDir = `${danglingAncestor}/../roles.local`;
|
|
||||||
|
|
||||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
|
|
||||||
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when relative dot-dot traversal crosses a dangling override ancestor', async () => {
|
|
||||||
const danglingAncestor = join(tmp, 'relative-dangling-ancestor');
|
|
||||||
await symlink(join(tmp, 'missing-relative-target'), danglingAncestor, 'dir');
|
|
||||||
overrideDir = `${relative(process.cwd(), danglingAncestor)}/../roles.local`;
|
|
||||||
expect(overrideDir.startsWith('/')).toBe(false);
|
|
||||||
|
|
||||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
|
|
||||||
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('revalidates a cached missing override before baseline fallback', async () => {
|
|
||||||
const cachedOverrideDir = join(tmp, 'mutable-ancestor', 'roles.local');
|
|
||||||
const [base, over] = await Promise.all([
|
|
||||||
extractClassesFromDir(rolesDir),
|
|
||||||
extractClassesFromDir(cachedOverrideDir),
|
|
||||||
]);
|
|
||||||
expect(over.scanState).toBe('missing');
|
|
||||||
await symlink(join(tmp, 'missing-mutable-target'), join(tmp, 'mutable-ancestor'), 'dir');
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await resolvePersonaFrom('code', {
|
|
||||||
rolesDir,
|
|
||||||
overrideDir: cachedOverrideDir,
|
|
||||||
base,
|
|
||||||
over,
|
|
||||||
}),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('revalidates a cached scanned override before baseline fallback', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
const [base, over] = await Promise.all([
|
|
||||||
extractClassesFromDir(rolesDir),
|
|
||||||
extractClassesFromDir(overrideDir),
|
|
||||||
]);
|
|
||||||
expect(over.scanState).toBe('scanned');
|
|
||||||
expect(over.classes.size).toBe(0);
|
|
||||||
|
|
||||||
await writeFile(join(overrideDir, 'engineering.md'), overridePersona('code', 'engineering'));
|
|
||||||
|
|
||||||
const resolved = await resolvePersonaFrom('code', {
|
|
||||||
rolesDir,
|
|
||||||
overrideDir,
|
|
||||||
base,
|
|
||||||
over,
|
|
||||||
});
|
|
||||||
expect(resolved?.layer).toBe('override');
|
|
||||||
expect(resolved?.file).toBe(join(overrideDir, 'engineering.md'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to baseline asynchronously and synchronously when override directory is missing', async () => {
|
|
||||||
const asyncResolution = await resolvePersona('code', { rolesDir, overrideDir });
|
|
||||||
const syncResolution = resolvePersonaSync('code', { rolesDir, overrideDir });
|
|
||||||
expect(asyncResolution?.layer).toBe('baseline');
|
|
||||||
expect(syncResolution?.layer).toBe('baseline');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed asynchronously when the canonical override exists but is unreadable', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await mkdir(join(overrideDir, 'code.md'));
|
|
||||||
|
|
||||||
expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not advertise a shadowed baseline whose canonical override is unreadable', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await mkdir(join(overrideDir, 'code.md'));
|
|
||||||
|
|
||||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(false);
|
|
||||||
expect(
|
|
||||||
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'code'),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not advertise baseline personas when the override directory is unscannable', async () => {
|
|
||||||
await writeFile(overrideDir, 'not a directory', 'utf8');
|
|
||||||
|
|
||||||
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
|
|
||||||
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not advertise baseline personas through a dangling override-directory symlink', async () => {
|
|
||||||
await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir');
|
|
||||||
|
|
||||||
expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set());
|
|
||||||
expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves baseline listings when the override directory is missing', async () => {
|
|
||||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true);
|
|
||||||
expect(
|
|
||||||
(await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code')
|
|
||||||
?.status,
|
|
||||||
).toBe('baseline');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not advertise an unreadable custom override as a valid persona class or status', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await mkdir(join(overrideDir, 'ghost.md'));
|
|
||||||
|
|
||||||
const extracted = await extractClassesFromDir(overrideDir);
|
|
||||||
expect(extracted.fileStems.has('ghost')).toBe(true);
|
|
||||||
expect(extracted.classes.has('ghost')).toBe(false);
|
|
||||||
expect((await listPersonaClasses({ rolesDir, overrideDir })).has('ghost')).toBe(false);
|
|
||||||
expect(
|
|
||||||
(await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'ghost'),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed synchronously when the canonical override exists but is unreadable', async () => {
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await mkdir(join(overrideDir, 'code.md'));
|
|
||||||
|
|
||||||
expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for an unknown class', async () => {
|
it('returns null for an unknown class', async () => {
|
||||||
expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull();
|
expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
* can reference a customized or user-added persona.
|
* can reference a customized or user-added persona.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { lstat, readFile, readdir, stat } from 'node:fs/promises';
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { basename, isAbsolute, join, sep } from 'node:path';
|
import { basename, join } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
|
|
||||||
function defaultMosaicHome(): string {
|
function defaultMosaicHome(): string {
|
||||||
@@ -53,136 +53,52 @@ export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
|
|||||||
const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g;
|
const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g;
|
||||||
/** Optional `domain: Y` marker that travels alongside the class in the prose. */
|
/** Optional `domain: Y` marker that travels alongside the class in the prose. */
|
||||||
const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/;
|
const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/;
|
||||||
|
/** LIBRARY.md persona rows: the first table cell is the persona name. */
|
||||||
|
const LIBRARY_ROW = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm;
|
||||||
|
|
||||||
/** Where a resolved persona's definition came from. */
|
/** Where a resolved persona's definition came from. */
|
||||||
export type PersonaLayer = 'baseline' | 'override';
|
export type PersonaLayer = 'baseline' | 'override';
|
||||||
|
|
||||||
export const ROLE_CLASS_ALIASES = Object.freeze({
|
|
||||||
implementer: 'code',
|
|
||||||
reviewer: 'review',
|
|
||||||
'operator-interaction': 'interaction',
|
|
||||||
} as const);
|
|
||||||
|
|
||||||
export interface CanonicalRoleClass {
|
|
||||||
readonly requestedClass: string;
|
|
||||||
readonly canonicalClass: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Canonicalize only the three explicitly approved compatibility aliases. */
|
|
||||||
export function canonicalizeRoleClass(requestedClass: string): CanonicalRoleClass {
|
|
||||||
const requested = requestedClass.trim();
|
|
||||||
const canonical = Object.hasOwn(ROLE_CLASS_ALIASES, requested)
|
|
||||||
? ROLE_CLASS_ALIASES[requested as keyof typeof ROLE_CLASS_ALIASES]
|
|
||||||
: requested;
|
|
||||||
return Object.freeze({ requestedClass: requested, canonicalClass: canonical });
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoleAuthority {
|
|
||||||
readonly mayMerge: boolean;
|
|
||||||
readonly mayIssueValidationCertificate: boolean;
|
|
||||||
readonly mayOrchestrate: boolean;
|
|
||||||
readonly mayManageTopology: boolean;
|
|
||||||
readonly mayIssueLeases: boolean;
|
|
||||||
readonly leasedCapacityOnly: boolean;
|
|
||||||
readonly requestStatusOnly: boolean;
|
|
||||||
readonly mayMutateRoster: boolean;
|
|
||||||
readonly mayMutateConfiguration: boolean;
|
|
||||||
readonly mayAccessCredentials: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NO_PROTECTED_AUTHORITY: RoleAuthority = Object.freeze({
|
|
||||||
mayMerge: false,
|
|
||||||
mayIssueValidationCertificate: false,
|
|
||||||
mayOrchestrate: false,
|
|
||||||
mayManageTopology: false,
|
|
||||||
mayIssueLeases: false,
|
|
||||||
leasedCapacityOnly: false,
|
|
||||||
requestStatusOnly: false,
|
|
||||||
mayMutateRoster: false,
|
|
||||||
mayMutateConfiguration: false,
|
|
||||||
mayAccessCredentials: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Immutable authority contracts keyed only by canonical class identity. */
|
|
||||||
export const ROLE_AUTHORITY_BY_CANONICAL_CLASS: Readonly<Record<string, RoleAuthority>> =
|
|
||||||
Object.freeze({
|
|
||||||
'merge-gate': Object.freeze({ ...NO_PROTECTED_AUTHORITY, mayMerge: true }),
|
|
||||||
validator: Object.freeze({
|
|
||||||
...NO_PROTECTED_AUTHORITY,
|
|
||||||
mayIssueValidationCertificate: true,
|
|
||||||
}),
|
|
||||||
orchestrator: Object.freeze({
|
|
||||||
...NO_PROTECTED_AUTHORITY,
|
|
||||||
mayOrchestrate: true,
|
|
||||||
mayManageTopology: true,
|
|
||||||
mayIssueLeases: true,
|
|
||||||
}),
|
|
||||||
'team-leader': Object.freeze({ ...NO_PROTECTED_AUTHORITY, leasedCapacityOnly: true }),
|
|
||||||
interaction: Object.freeze({ ...NO_PROTECTED_AUTHORITY, requestStatusOnly: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Return protected authority for an already-canonical class; custom classes get none. */
|
|
||||||
export function authorityForCanonicalClass(canonicalClass: string): RoleAuthority {
|
|
||||||
return Object.hasOwn(ROLE_AUTHORITY_BY_CANONICAL_CLASS, canonicalClass)
|
|
||||||
? ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass]!
|
|
||||||
: NO_PROTECTED_AUTHORITY;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One discovered persona file (a single role contract on disk). */
|
/** One discovered persona file (a single role contract on disk). */
|
||||||
export interface PersonaFile {
|
export interface PersonaFile {
|
||||||
klass: string;
|
klass: string;
|
||||||
/** The markdown file the class was found in. */
|
/** The markdown file the class was found in. */
|
||||||
file: string;
|
file: string;
|
||||||
/** True when the first class marker, rather than filename, defined this mapping. */
|
|
||||||
markerDefined?: boolean;
|
|
||||||
domain?: string;
|
domain?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DirScanState = 'scanned' | 'missing' | 'error';
|
|
||||||
|
|
||||||
/** The set of persona classes a directory of role contracts defines. */
|
/** The set of persona classes a directory of role contracts defines. */
|
||||||
export interface DirClasses {
|
export interface DirClasses {
|
||||||
/** Whether the directory was scanned, absent, or present-but-unscannable. */
|
/** Every class name the dir contributes (markers + filenames + LIBRARY rows). */
|
||||||
scanState: DirScanState;
|
|
||||||
/** Every readable class name the directory contributes by filename or first marker. */
|
|
||||||
classes: Set<string>;
|
classes: Set<string>;
|
||||||
/** Filename stems present on disk, retained even when a markdown entry is unreadable. */
|
|
||||||
fileStems: Set<string>;
|
|
||||||
/** For classes whose file carries a marker, the file + domain that defined it. */
|
/** For classes whose file carries a marker, the file + domain that defined it. */
|
||||||
byClass: Map<string, PersonaFile>;
|
byClass: Map<string, PersonaFile>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan one directory of role contracts and extract readable persona identities.
|
* Scan one directory of role contracts and extract the persona classes it
|
||||||
* Valid identities come only from a successfully read non-LIBRARY filename and
|
* defines. THIS is the shared extraction both fleet-personas and fleet-profiles
|
||||||
* that file's first `class:` marker. LIBRARY rows and later prose mentions are
|
* rely on. Sources, unioned (each needed — see module doc):
|
||||||
* index/reference data, not independently readable role contracts.
|
* 1. inline `class: X` markers in roles/*.md (primary; may wrap a newline),
|
||||||
|
* 2. persona-name cells from LIBRARY.md index tables,
|
||||||
|
* 3. the role filename stem (covers marker-less alias docs like planner).
|
||||||
*
|
*
|
||||||
* Missing directories are distinguished from present-but-unscannable paths.
|
* Missing dir / unreadable files degrade gracefully to whatever was found.
|
||||||
* `byClass` records the first marker-defined file+domain so the resolver can map
|
* `byClass` records the defining file+domain for marker-bearing classes so the
|
||||||
* a class back to its contract; marker-less readable files map by filename.
|
* resolver can map a class back to its file; filename-only and LIBRARY-only
|
||||||
|
* classes still appear in `classes` for membership checks.
|
||||||
*/
|
*/
|
||||||
export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
|
export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
|
||||||
const acc: DirClasses = {
|
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
|
||||||
scanState: 'scanned',
|
|
||||||
classes: new Set<string>(),
|
|
||||||
fileStems: new Set<string>(),
|
|
||||||
byClass: new Map<string, PersonaFile>(),
|
|
||||||
};
|
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = await readdir(dir);
|
entries = await readdir(dir);
|
||||||
} catch (error) {
|
} catch {
|
||||||
acc.scanState = await classifyScanFailure(dir, error);
|
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.endsWith('.md')) continue;
|
if (!entry.endsWith('.md')) continue;
|
||||||
// Preserve entry presence separately from valid readable classes. Resolvers
|
|
||||||
// use it to fail closed on an explicit unreadable canonical override without
|
|
||||||
// advertising that override through class listing/status APIs.
|
|
||||||
if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md'));
|
|
||||||
let text: string;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
text = await readFile(join(dir, entry), 'utf8');
|
text = await readFile(join(dir, entry), 'utf8');
|
||||||
@@ -201,25 +117,16 @@ export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
|
|||||||
* cannot await. Missing dir / unreadable files degrade gracefully.
|
* cannot await. Missing dir / unreadable files degrade gracefully.
|
||||||
*/
|
*/
|
||||||
export function extractClassesFromDirSync(dir: string): DirClasses {
|
export function extractClassesFromDirSync(dir: string): DirClasses {
|
||||||
const acc: DirClasses = {
|
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
|
||||||
scanState: 'scanned',
|
|
||||||
classes: new Set<string>(),
|
|
||||||
fileStems: new Set<string>(),
|
|
||||||
byClass: new Map<string, PersonaFile>(),
|
|
||||||
};
|
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = readdirSync(dir);
|
entries = readdirSync(dir);
|
||||||
} catch (error) {
|
} catch {
|
||||||
acc.scanState = classifyScanFailureSync(dir, error);
|
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.endsWith('.md')) continue;
|
if (!entry.endsWith('.md')) continue;
|
||||||
// Keep sync extraction equivalent to the async scanner, including unreadable
|
|
||||||
// filename presence kept separate from valid readable classes.
|
|
||||||
if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md'));
|
|
||||||
let text: string;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
text = readFileSync(join(dir, entry), 'utf8');
|
text = readFileSync(join(dir, entry), 'utf8');
|
||||||
@@ -231,74 +138,6 @@ export function extractClassesFromDirSync(dir: string): DirClasses {
|
|||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function classifyScanFailure(dir: string, error: unknown): Promise<DirScanState> {
|
|
||||||
if (!isMissingPathError(error)) return 'error';
|
|
||||||
return (await hasBrokenSymlinkInPath(dir)) ? 'error' : 'missing';
|
|
||||||
}
|
|
||||||
|
|
||||||
function classifyScanFailureSync(dir: string, error: unknown): DirScanState {
|
|
||||||
if (!isMissingPathError(error)) return 'error';
|
|
||||||
return hasBrokenSymlinkInPathSync(dir) ? 'error' : 'missing';
|
|
||||||
}
|
|
||||||
|
|
||||||
function traversalPrefixes(path: string): string[] {
|
|
||||||
const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`;
|
|
||||||
const parts = absolute.split(sep);
|
|
||||||
const prefixes: string[] = [];
|
|
||||||
let current: string = sep;
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!part) continue;
|
|
||||||
current = current === sep ? `${sep}${part}` : `${current}${sep}${part}`;
|
|
||||||
prefixes.push(current);
|
|
||||||
}
|
|
||||||
return prefixes;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hasBrokenSymlinkInPath(path: string): Promise<boolean> {
|
|
||||||
for (const current of traversalPrefixes(path)) {
|
|
||||||
try {
|
|
||||||
const entry = await lstat(current);
|
|
||||||
if (entry.isSymbolicLink()) {
|
|
||||||
try {
|
|
||||||
await stat(current);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isMissingPathError(error)) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasBrokenSymlinkInPathSync(path: string): boolean {
|
|
||||||
for (const current of traversalPrefixes(path)) {
|
|
||||||
try {
|
|
||||||
const entry = lstatSync(current);
|
|
||||||
if (entry.isSymbolicLink()) {
|
|
||||||
try {
|
|
||||||
statSync(current);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!isMissingPathError(error)) return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMissingPathError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
typeof error === 'object' &&
|
|
||||||
error !== null &&
|
|
||||||
'code' in error &&
|
|
||||||
(error as { code?: unknown }).code === 'ENOENT'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply the class-extraction rules for ONE role file's text into `acc`. Pure
|
* Apply the class-extraction rules for ONE role file's text into `acc`. Pure
|
||||||
* over already-read content, so the async and sync directory scanners share a
|
* over already-read content, so the async and sync directory scanners share a
|
||||||
@@ -307,26 +146,33 @@ function isMissingPathError(error: unknown): boolean {
|
|||||||
*/
|
*/
|
||||||
function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void {
|
function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void {
|
||||||
const { classes, byClass } = acc;
|
const { classes, byClass } = acc;
|
||||||
if (entry === 'LIBRARY.md') return;
|
if (entry === 'LIBRARY.md') {
|
||||||
|
for (const m of text.matchAll(LIBRARY_ROW)) {
|
||||||
// An explicit first marker owns identity. Filename identity is a fallback only
|
const name = m[1];
|
||||||
// for markerless compatibility contracts such as planner.md.
|
if (name && name !== 'persona') classes.add(name);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The filename stem is itself a valid class (covers marker-less alias docs).
|
||||||
const stem = basename(entry, '.md');
|
const stem = basename(entry, '.md');
|
||||||
|
classes.add(stem);
|
||||||
const domainMatch = DOMAIN_MARKER.exec(text);
|
const domainMatch = DOMAIN_MARKER.exec(text);
|
||||||
const domain = domainMatch?.[1];
|
const domain = domainMatch?.[1];
|
||||||
const firstMarker = text.matchAll(CLASS_MARKER).next().value as RegExpExecArray | undefined;
|
let markedClassForFile: string | undefined;
|
||||||
const markedClassForFile = firstMarker?.[1];
|
for (const m of text.matchAll(CLASS_MARKER)) {
|
||||||
if (markedClassForFile) {
|
const klass = m[1];
|
||||||
classes.add(markedClassForFile);
|
if (!klass) continue;
|
||||||
byClass.set(markedClassForFile, {
|
classes.add(klass);
|
||||||
klass: markedClassForFile,
|
// Record the FIRST marker as the file's defining class (the prose names
|
||||||
file: join(dir, entry),
|
// the persona's own class up top; later mentions reference siblings).
|
||||||
markerDefined: true,
|
if (!markedClassForFile) {
|
||||||
...(domain ? { domain } : {}),
|
markedClassForFile = klass;
|
||||||
});
|
byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) });
|
||||||
} else {
|
}
|
||||||
classes.add(stem);
|
}
|
||||||
if (!byClass.has(stem)) byClass.set(stem, { klass: stem, file: join(dir, entry) });
|
// A marker-less file still maps its stem to itself (no domain known).
|
||||||
|
if (!markedClassForFile && !byClass.has(stem)) {
|
||||||
|
byClass.set(stem, { klass: stem, file: join(dir, entry) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,19 +193,24 @@ function resolveDirs(opts: PersonaDirs): { rolesDir: string; overrideDir: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every class with an actually readable winning contract. Discovery applies the
|
* UNION of baseline classes and override classes. Overrides may ADD entirely new
|
||||||
* same override shadow/fail-closed semantics as resolution, so callers never
|
* classes not present in the baseline, so callers (e.g. profile roster
|
||||||
* advertise a class that cannot be used.
|
* validation) treat a user-added persona as a real class.
|
||||||
*/
|
*/
|
||||||
export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> {
|
export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> {
|
||||||
const { personas } = await collectUsablePersonas(opts);
|
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||||
return new Set(personas.keys());
|
const [base, over] = await Promise.all([
|
||||||
|
extractClassesFromDir(rolesDir),
|
||||||
|
extractClassesFromDir(overrideDir),
|
||||||
|
]);
|
||||||
|
const union = new Set<string>(base.classes);
|
||||||
|
for (const c of over.classes) union.add(c);
|
||||||
|
return union;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PersonaStatus = 'baseline' | 'overridden' | 'custom';
|
export type PersonaStatus = 'baseline' | 'overridden' | 'custom';
|
||||||
|
|
||||||
export interface PersonaResolution extends CanonicalRoleClass {
|
export interface PersonaResolution {
|
||||||
/** Compatibility name for the canonical class. */
|
|
||||||
klass: string;
|
klass: string;
|
||||||
layer: PersonaLayer;
|
layer: PersonaLayer;
|
||||||
/** The file the resolved persona was read from (override wins). */
|
/** The file the resolved persona was read from (override wins). */
|
||||||
@@ -368,118 +219,6 @@ export interface PersonaResolution extends CanonicalRoleClass {
|
|||||||
domain?: string;
|
domain?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readPersonaFromLayer(
|
|
||||||
requestedClass: string,
|
|
||||||
canonicalClass: string,
|
|
||||||
dir: string,
|
|
||||||
extracted: DirClasses,
|
|
||||||
layer: PersonaLayer,
|
|
||||||
): Promise<PersonaResolution | null> {
|
|
||||||
const pf = extracted.byClass.get(canonicalClass);
|
|
||||||
if (!pf) {
|
|
||||||
if (!extracted.classes.has(canonicalClass)) return null;
|
|
||||||
const byName = join(dir, `${canonicalClass}.md`);
|
|
||||||
try {
|
|
||||||
const content = await readFile(byName, 'utf8');
|
|
||||||
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
|
|
||||||
| RegExpExecArray
|
|
||||||
| undefined;
|
|
||||||
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
|
|
||||||
const dm = DOMAIN_MARKER.exec(content);
|
|
||||||
return {
|
|
||||||
requestedClass,
|
|
||||||
canonicalClass,
|
|
||||||
klass: canonicalClass,
|
|
||||||
layer,
|
|
||||||
file: byName,
|
|
||||||
content,
|
|
||||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const content = await readFile(pf.file, 'utf8');
|
|
||||||
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
|
|
||||||
| RegExpExecArray
|
|
||||||
| undefined;
|
|
||||||
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
|
|
||||||
const dm = DOMAIN_MARKER.exec(content);
|
|
||||||
return {
|
|
||||||
requestedClass,
|
|
||||||
canonicalClass,
|
|
||||||
klass: canonicalClass,
|
|
||||||
layer,
|
|
||||||
file: pf.file,
|
|
||||||
content,
|
|
||||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function overrideShadowsBaseline(over: DirClasses, canonicalClass: string): boolean {
|
|
||||||
return (
|
|
||||||
over.scanState === 'error' ||
|
|
||||||
over.fileStems.has(canonicalClass) ||
|
|
||||||
over.byClass.has(canonicalClass)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPersonaFromLayerSync(
|
|
||||||
requestedClass: string,
|
|
||||||
canonicalClass: string,
|
|
||||||
dir: string,
|
|
||||||
extracted: DirClasses,
|
|
||||||
layer: PersonaLayer,
|
|
||||||
): PersonaResolution | null {
|
|
||||||
const pf = extracted.byClass.get(canonicalClass);
|
|
||||||
if (!pf) {
|
|
||||||
if (!extracted.classes.has(canonicalClass)) return null;
|
|
||||||
const byName = join(dir, `${canonicalClass}.md`);
|
|
||||||
try {
|
|
||||||
const content = readFileSync(byName, 'utf8');
|
|
||||||
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
|
|
||||||
| RegExpExecArray
|
|
||||||
| undefined;
|
|
||||||
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
|
|
||||||
const dm = DOMAIN_MARKER.exec(content);
|
|
||||||
return {
|
|
||||||
requestedClass,
|
|
||||||
canonicalClass,
|
|
||||||
klass: canonicalClass,
|
|
||||||
layer,
|
|
||||||
file: byName,
|
|
||||||
content,
|
|
||||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const content = readFileSync(pf.file, 'utf8');
|
|
||||||
const currentMarker = content.matchAll(CLASS_MARKER).next().value as
|
|
||||||
| RegExpExecArray
|
|
||||||
| undefined;
|
|
||||||
if (currentMarker && currentMarker[1] !== canonicalClass) return null;
|
|
||||||
const dm = DOMAIN_MARKER.exec(content);
|
|
||||||
return {
|
|
||||||
requestedClass,
|
|
||||||
canonicalClass,
|
|
||||||
klass: canonicalClass,
|
|
||||||
layer,
|
|
||||||
file: pf.file,
|
|
||||||
content,
|
|
||||||
...(dm?.[1] ? { domain: dm[1] } : {}),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a persona class to its winning definition: the override file if
|
* Resolve a persona class to its winning definition: the override file if
|
||||||
* roles.local/ defines that class, else the baseline. Match by inline `class:`
|
* roles.local/ defines that class, else the baseline. Match by inline `class:`
|
||||||
@@ -506,32 +245,42 @@ export async function resolvePersona(
|
|||||||
* is identical to {@link resolvePersona}: override layer wins, then baseline.
|
* is identical to {@link resolvePersona}: override layer wins, then baseline.
|
||||||
*/
|
*/
|
||||||
export async function resolvePersonaFrom(
|
export async function resolvePersonaFrom(
|
||||||
requestedClass: string,
|
klass: string,
|
||||||
layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses },
|
layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses },
|
||||||
): Promise<PersonaResolution | null> {
|
): Promise<PersonaResolution | null> {
|
||||||
const { requestedClass: requested, canonicalClass: klass } =
|
|
||||||
canonicalizeRoleClass(requestedClass);
|
|
||||||
const { rolesDir, overrideDir, base, over } = layers;
|
const { rolesDir, overrideDir, base, over } = layers;
|
||||||
|
|
||||||
const override = await readPersonaFromLayer(requested, klass, overrideDir, over, 'override');
|
const fromLayer = async (
|
||||||
if (override) return override;
|
dir: string,
|
||||||
// An observed explicit override that cannot resolve/read shadows the baseline.
|
extracted: DirClasses,
|
||||||
if (overrideShadowsBaseline(over, klass)) return null;
|
layer: PersonaLayer,
|
||||||
|
): Promise<PersonaResolution | null> => {
|
||||||
|
// Prefer the marker-defined file; fall back to the filename stem.
|
||||||
|
let pf = extracted.byClass.get(klass);
|
||||||
|
if (!pf) {
|
||||||
|
const byName = join(dir, `${klass}.md`);
|
||||||
|
if (!extracted.classes.has(klass)) return null;
|
||||||
|
// Class known only via filename/LIBRARY: read the stem file if present.
|
||||||
|
try {
|
||||||
|
const content = await readFile(byName, 'utf8');
|
||||||
|
const dm = DOMAIN_MARKER.exec(content);
|
||||||
|
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const content = await readFile(pf.file, 'utf8');
|
||||||
|
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Cached scans are only snapshots. Re-scan immediately before baseline fallback
|
return (
|
||||||
// so a newly created canonical or marker-defined override cannot be skipped.
|
(await fromLayer(overrideDir, over, 'override')) ??
|
||||||
const currentOver = await extractClassesFromDir(overrideDir);
|
(await fromLayer(rolesDir, base, 'baseline'))
|
||||||
const currentOverride = await readPersonaFromLayer(
|
|
||||||
requested,
|
|
||||||
klass,
|
|
||||||
overrideDir,
|
|
||||||
currentOver,
|
|
||||||
'override',
|
|
||||||
);
|
);
|
||||||
if (currentOverride) return currentOverride;
|
|
||||||
if (overrideShadowsBaseline(currentOver, klass)) return null;
|
|
||||||
if (currentOver.scanState === 'error') return null;
|
|
||||||
return readPersonaFromLayer(requested, klass, rolesDir, base, 'baseline');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -543,55 +292,40 @@ export async function resolvePersonaFrom(
|
|||||||
* one module so the launch-time and command-time resolutions never diverge.
|
* one module so the launch-time and command-time resolutions never diverge.
|
||||||
*/
|
*/
|
||||||
export function resolvePersonaSync(
|
export function resolvePersonaSync(
|
||||||
requestedClass: string,
|
klass: string,
|
||||||
opts: PersonaDirs = {},
|
opts: PersonaDirs = {},
|
||||||
): PersonaResolution | null {
|
): PersonaResolution | null {
|
||||||
const { requestedClass: requested, canonicalClass: klass } =
|
|
||||||
canonicalizeRoleClass(requestedClass);
|
|
||||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||||
const base = extractClassesFromDirSync(rolesDir);
|
const base = extractClassesFromDirSync(rolesDir);
|
||||||
const over = extractClassesFromDirSync(overrideDir);
|
const over = extractClassesFromDirSync(overrideDir);
|
||||||
|
|
||||||
const override = readPersonaFromLayerSync(requested, klass, overrideDir, over, 'override');
|
const fromLayer = (
|
||||||
if (override) return override;
|
dir: string,
|
||||||
if (overrideShadowsBaseline(over, klass)) return null;
|
extracted: DirClasses,
|
||||||
return readPersonaFromLayerSync(requested, klass, rolesDir, base, 'baseline');
|
layer: PersonaLayer,
|
||||||
|
): PersonaResolution | null => {
|
||||||
|
// Prefer the marker-defined file; fall back to the filename stem.
|
||||||
|
const pf = extracted.byClass.get(klass);
|
||||||
|
if (!pf) {
|
||||||
|
if (!extracted.classes.has(klass)) return null;
|
||||||
|
const byName = join(dir, `${klass}.md`);
|
||||||
|
try {
|
||||||
|
const content = readFileSync(byName, 'utf8');
|
||||||
|
const dm = DOMAIN_MARKER.exec(content);
|
||||||
|
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UsablePersonaIndex {
|
|
||||||
personas: Map<string, PersonaResolution>;
|
|
||||||
readableBaseline: Set<string>;
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
async function collectUsablePersonas(opts: PersonaDirs): Promise<UsablePersonaIndex> {
|
const content = readFileSync(pf.file, 'utf8');
|
||||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
|
||||||
const [base, over] = await Promise.all([
|
} catch {
|
||||||
extractClassesFromDir(rolesDir),
|
return null;
|
||||||
extractClassesFromDir(overrideDir),
|
|
||||||
]);
|
|
||||||
if (over.scanState === 'error') {
|
|
||||||
return { personas: new Map(), readableBaseline: new Set() };
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const candidates = new Set<string>([...base.classes, ...over.classes]);
|
return fromLayer(overrideDir, over, 'override') ?? fromLayer(rolesDir, base, 'baseline');
|
||||||
const checked = await Promise.all(
|
|
||||||
[...candidates].map(async (requestedClass) => {
|
|
||||||
const { canonicalClass } = canonicalizeRoleClass(requestedClass);
|
|
||||||
const [persona, baseline] = await Promise.all([
|
|
||||||
resolvePersonaFrom(requestedClass, { rolesDir, overrideDir, base, over }),
|
|
||||||
readPersonaFromLayer(requestedClass, canonicalClass, rolesDir, base, 'baseline'),
|
|
||||||
]);
|
|
||||||
return { requestedClass, persona, baseline };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const personas = new Map<string, PersonaResolution>();
|
|
||||||
const readableBaseline = new Set<string>();
|
|
||||||
for (const { requestedClass, persona, baseline } of checked) {
|
|
||||||
if (persona) personas.set(requestedClass, persona);
|
|
||||||
if (baseline) readableBaseline.add(requestedClass);
|
|
||||||
}
|
|
||||||
return { personas, readableBaseline };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonaStatusEntry {
|
export interface PersonaStatusEntry {
|
||||||
@@ -608,20 +342,24 @@ export interface PersonaStatusEntry {
|
|||||||
* Domain is taken from the WINNING layer (override domain wins if present).
|
* Domain is taken from the WINNING layer (override domain wins if present).
|
||||||
*/
|
*/
|
||||||
export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> {
|
export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> {
|
||||||
const { personas, readableBaseline } = await collectUsablePersonas(opts);
|
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||||
const entries = [...personas.entries()].map(([klass, persona]): PersonaStatusEntry => {
|
const [base, over] = await Promise.all([
|
||||||
const status: PersonaStatus =
|
extractClassesFromDir(rolesDir),
|
||||||
persona.layer === 'baseline'
|
extractClassesFromDir(overrideDir),
|
||||||
? 'baseline'
|
]);
|
||||||
: readableBaseline.has(klass)
|
|
||||||
? 'overridden'
|
const all = new Set<string>([...base.classes, ...over.classes]);
|
||||||
: 'custom';
|
const domainOf = (extracted: DirClasses, klass: string): string | undefined =>
|
||||||
return {
|
extracted.byClass.get(klass)?.domain;
|
||||||
klass,
|
|
||||||
status,
|
const entries: PersonaStatusEntry[] = [];
|
||||||
...(persona.domain ? { domain: persona.domain } : {}),
|
for (const klass of all) {
|
||||||
};
|
const inBase = base.classes.has(klass);
|
||||||
});
|
const inOver = over.classes.has(klass);
|
||||||
|
const status: PersonaStatus = inOver ? (inBase ? 'overridden' : 'custom') : 'baseline';
|
||||||
|
const domain = (inOver ? domainOf(over, klass) : undefined) ?? domainOf(base, klass);
|
||||||
|
entries.push({ klass, status, ...(domain ? { domain } : {}) });
|
||||||
|
}
|
||||||
entries.sort((a, b) => a.klass.localeCompare(b.klass));
|
entries.sort((a, b) => a.klass.localeCompare(b.klass));
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,91 +203,6 @@ describe('loadProfiles with a temp override dir', () => {
|
|||||||
await rm(dir, { recursive: true, force: true });
|
await rm(dir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('canonicalizes approved aliases across lead, floor, roster, and topology', async () => {
|
|
||||||
await writeFile(
|
|
||||||
join(dir, 'aliases.yaml'),
|
|
||||||
[
|
|
||||||
'id: aliases',
|
|
||||||
'title: Aliases',
|
|
||||||
'description: legacy class compatibility',
|
|
||||||
'lead: operator-interaction',
|
|
||||||
'floor: [operator-interaction]',
|
|
||||||
'roster:',
|
|
||||||
' - class: operator-interaction',
|
|
||||||
' - class: implementer',
|
|
||||||
' reports_to: operator-interaction',
|
|
||||||
' - class: reviewer',
|
|
||||||
' reports_to: operator-interaction',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const profile = await loadProfile('aliases', {
|
|
||||||
profilesDir: dir,
|
|
||||||
rolesDir,
|
|
||||||
overrideDir: join(dir, 'roles.local'),
|
|
||||||
});
|
|
||||||
expect(profile.lead).toBe('interaction');
|
|
||||||
expect(profile.floor).toEqual(['interaction']);
|
|
||||||
expect(profile.roster).toEqual([
|
|
||||||
{ class: 'interaction', multiplicity: 1 },
|
|
||||||
{ class: 'code', reportsTo: 'interaction', multiplicity: 1 },
|
|
||||||
{ class: 'review', reportsTo: 'interaction', multiplicity: 1 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects roster entries that collapse to the same canonical class', async () => {
|
|
||||||
await writeFile(
|
|
||||||
join(dir, 'alias-collision.yaml'),
|
|
||||||
[
|
|
||||||
'id: alias-collision',
|
|
||||||
'title: Alias collision',
|
|
||||||
'description: ambiguous canonical topology',
|
|
||||||
'lead: orchestrator',
|
|
||||||
'floor: [orchestrator]',
|
|
||||||
'roster:',
|
|
||||||
' - class: orchestrator',
|
|
||||||
' - class: code',
|
|
||||||
' reports_to: orchestrator',
|
|
||||||
' - class: implementer',
|
|
||||||
' reports_to: orchestrator',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
loadProfile('alias-collision', {
|
|
||||||
profilesDir: dir,
|
|
||||||
rolesDir,
|
|
||||||
overrideDir: join(dir, 'roles.local'),
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/duplicate classes after canonicalization/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows a readable lead or floor persona that is not itself a roster seat', async () => {
|
|
||||||
await writeFile(
|
|
||||||
join(dir, 'external-topology.yaml'),
|
|
||||||
[
|
|
||||||
'id: external-topology',
|
|
||||||
'title: External topology',
|
|
||||||
'description: structural compatibility',
|
|
||||||
'lead: orchestrator',
|
|
||||||
'floor: [enhancer]',
|
|
||||||
'roster:',
|
|
||||||
' - class: code',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const profile = await loadProfile('external-topology', {
|
|
||||||
profilesDir: dir,
|
|
||||||
rolesDir,
|
|
||||||
overrideDir: join(dir, 'roles.local'),
|
|
||||||
});
|
|
||||||
expect(profile.lead).toBe('orchestrator');
|
|
||||||
expect(profile.floor).toEqual(['enhancer']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when a profile references an unknown class (validated against real roles)', async () => {
|
it('throws when a profile references an unknown class (validated against real roles)', async () => {
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(dir, 'bad.yaml'),
|
join(dir, 'bad.yaml'),
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
defaultOverrideDir,
|
defaultOverrideDir,
|
||||||
extractClassesFromDir,
|
extractClassesFromDir,
|
||||||
listPersonaClasses as listOverrideAwarePersonaClasses,
|
listPersonaClasses as listOverrideAwarePersonaClasses,
|
||||||
resolvePersonaFrom,
|
|
||||||
} from './fleet-personas.js';
|
} from './fleet-personas.js';
|
||||||
|
|
||||||
function defaultMosaicHome(): string {
|
function defaultMosaicHome(): string {
|
||||||
@@ -200,61 +199,6 @@ export function validateProfile(profile: FleetProfile, validClasses: Set<string>
|
|||||||
return problems;
|
return problems;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveProfilePersonas(
|
|
||||||
profile: FleetProfile,
|
|
||||||
rolesDir: string,
|
|
||||||
overrideDir: string,
|
|
||||||
): Promise<FleetProfile> {
|
|
||||||
const [base, over] = await Promise.all([
|
|
||||||
extractClassesFromDir(rolesDir),
|
|
||||||
extractClassesFromDir(overrideDir),
|
|
||||||
]);
|
|
||||||
const requestedClasses = new Set<string>([
|
|
||||||
profile.lead,
|
|
||||||
...profile.floor,
|
|
||||||
...profile.roster.flatMap((entry: ProfileRosterEntry): string[] =>
|
|
||||||
entry.reportsTo ? [entry.class, entry.reportsTo] : [entry.class],
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
const canonicalByRequested = new Map<string, string>();
|
|
||||||
for (const requestedClass of requestedClasses) {
|
|
||||||
const resolved = await resolvePersonaFrom(requestedClass, {
|
|
||||||
rolesDir,
|
|
||||||
overrideDir,
|
|
||||||
base,
|
|
||||||
over,
|
|
||||||
});
|
|
||||||
if (!resolved || resolved.content.trim() === '') {
|
|
||||||
throw new Error(`persona class "${requestedClass}" does not resolve to a readable persona`);
|
|
||||||
}
|
|
||||||
canonicalByRequested.set(requestedClass, resolved.canonicalClass);
|
|
||||||
}
|
|
||||||
const canonical = (requestedClass: string): string =>
|
|
||||||
canonicalByRequested.get(requestedClass) ?? requestedClass;
|
|
||||||
const roster = profile.roster.map(
|
|
||||||
(entry: ProfileRosterEntry): ProfileRosterEntry => ({
|
|
||||||
class: canonical(entry.class),
|
|
||||||
multiplicity: entry.multiplicity,
|
|
||||||
...(entry.reportsTo ? { reportsTo: canonical(entry.reportsTo) } : {}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const canonicalRosterClasses = roster.map((entry: ProfileRosterEntry): string => entry.class);
|
|
||||||
if (new Set(canonicalRosterClasses).size !== canonicalRosterClasses.length) {
|
|
||||||
throw new Error('profile roster contains duplicate classes after canonicalization');
|
|
||||||
}
|
|
||||||
const resolvedProfile: FleetProfile = {
|
|
||||||
...profile,
|
|
||||||
lead: canonical(profile.lead),
|
|
||||||
floor: profile.floor.map(canonical),
|
|
||||||
roster,
|
|
||||||
};
|
|
||||||
const problems = validateProfile(resolvedProfile, new Set(canonicalByRequested.values()));
|
|
||||||
if (problems.length > 0) {
|
|
||||||
throw new Error(problems.join('\n - '));
|
|
||||||
}
|
|
||||||
return resolvedProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoadProfilesOptions {
|
export interface LoadProfilesOptions {
|
||||||
/** Override the profiles dir (tests). Defaults to <mosaicHome>/fleet/profiles. */
|
/** Override the profiles dir (tests). Defaults to <mosaicHome>/fleet/profiles. */
|
||||||
profilesDir?: string;
|
profilesDir?: string;
|
||||||
@@ -293,8 +237,10 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
|
|||||||
}
|
}
|
||||||
files.sort();
|
files.sort();
|
||||||
|
|
||||||
// Validation resolves each reference to an actually readable winning persona;
|
// Override-aware: a profile may reference a user-customized or user-ADDED
|
||||||
// LIBRARY membership alone is not semantic success.
|
// persona living in the roles.local/ layer (H4), so validate against the
|
||||||
|
// baseline ⊕ override union, not the baseline alone.
|
||||||
|
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
|
||||||
const profiles: FleetProfile[] = [];
|
const profiles: FleetProfile[] = [];
|
||||||
const seen = new Map<string, string>();
|
const seen = new Map<string, string>();
|
||||||
|
|
||||||
@@ -309,14 +255,11 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
|
|||||||
}
|
}
|
||||||
seen.set(profile.id, file);
|
seen.set(profile.id, file);
|
||||||
|
|
||||||
let resolvedProfile: FleetProfile;
|
const problems = validateProfile(profile, validClasses);
|
||||||
try {
|
if (problems.length > 0) {
|
||||||
resolvedProfile = await resolveProfilePersonas(profile, rolesDir, overrideDir);
|
throw new Error(`Profile ${file} is invalid:\n - ${problems.join('\n - ')}`);
|
||||||
} catch (error: unknown) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new Error(`Profile ${file} is invalid:\n - ${detail}`);
|
|
||||||
}
|
}
|
||||||
profiles.push(resolvedProfile);
|
profiles.push(profile);
|
||||||
}
|
}
|
||||||
return profiles;
|
return profiles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,46 +172,6 @@ describe('override-aware persona validation', () => {
|
|||||||
expect(result.summary).toContain('persona=override');
|
expect(result.summary).toContain('persona=override');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('canonicalizes aliased profile classes and topology identities before emission', async () => {
|
|
||||||
const overrideDir = join(dir, 'roles.local');
|
|
||||||
const customProfilesDir = join(dir, 'profiles');
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
await mkdir(customProfilesDir, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
join(customProfilesDir, 'aliases.yaml'),
|
|
||||||
[
|
|
||||||
'id: aliases',
|
|
||||||
'title: Aliases',
|
|
||||||
'description: legacy aliases',
|
|
||||||
'lead: operator-interaction',
|
|
||||||
'floor: [operator-interaction]',
|
|
||||||
'roster:',
|
|
||||||
' - class: operator-interaction',
|
|
||||||
' - class: implementer',
|
|
||||||
' reports_to: operator-interaction',
|
|
||||||
' - class: reviewer',
|
|
||||||
' reports_to: operator-interaction',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await runProvision('aliases', {
|
|
||||||
mosaicHome: dir,
|
|
||||||
profilesDir: customProfilesDir,
|
|
||||||
rolesDir,
|
|
||||||
overrideDir,
|
|
||||||
full: true,
|
|
||||||
});
|
|
||||||
expect(result.yaml).toContain('name: interaction');
|
|
||||||
expect(result.yaml).toContain('class: interaction');
|
|
||||||
expect(result.yaml).toContain('name: code');
|
|
||||||
expect(result.yaml).toContain('class: code');
|
|
||||||
expect(result.yaml).toContain('name: review');
|
|
||||||
expect(result.yaml).toContain('class: review');
|
|
||||||
expect(result.yaml).not.toContain('class: implementer');
|
|
||||||
expect(result.summary).toContain('reports_to=interaction');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FAILS with a clear message when a profile references a bogus class', async () => {
|
it('FAILS with a clear message when a profile references a bogus class', async () => {
|
||||||
const customProfilesDir = join(dir, 'profiles');
|
const customProfilesDir = join(dir, 'profiles');
|
||||||
await mkdir(customProfilesDir, { recursive: true });
|
await mkdir(customProfilesDir, { recursive: true });
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ import type { Command } from 'commander';
|
|||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import {
|
import {
|
||||||
loadProfile,
|
loadProfile,
|
||||||
|
validateProfile,
|
||||||
type FleetProfile,
|
type FleetProfile,
|
||||||
type ProfileRosterEntry,
|
type ProfileRosterEntry,
|
||||||
defaultProfilesDir,
|
defaultProfilesDir,
|
||||||
defaultRolesDir,
|
defaultRolesDir,
|
||||||
resolveProfilePersonas,
|
listPersonaClassesWithOverrides,
|
||||||
} from './fleet-profiles.js';
|
} from './fleet-profiles.js';
|
||||||
import {
|
import {
|
||||||
defaultOverrideDir,
|
defaultOverrideDir,
|
||||||
@@ -200,35 +201,18 @@ export async function generateRoster(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canonicalEntry: ProfileRosterEntry = {
|
const runtimeChoice = resolveSeatRuntime(entry.class, isFloor, isLead);
|
||||||
class: resolved.canonicalClass,
|
for (const name of seatNames(entry)) {
|
||||||
multiplicity: entry.multiplicity,
|
|
||||||
...(entry.reportsTo
|
|
||||||
? {
|
|
||||||
reportsTo:
|
|
||||||
(
|
|
||||||
await resolvePersonaFrom(entry.reportsTo, {
|
|
||||||
rolesDir,
|
|
||||||
overrideDir,
|
|
||||||
base,
|
|
||||||
over,
|
|
||||||
})
|
|
||||||
)?.canonicalClass ?? entry.reportsTo,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
const runtimeChoice = resolveSeatRuntime(resolved.canonicalClass, isFloor, isLead);
|
|
||||||
for (const name of seatNames(canonicalEntry)) {
|
|
||||||
const seat: GeneratedSeat = {
|
const seat: GeneratedSeat = {
|
||||||
name,
|
name,
|
||||||
className: resolved.canonicalClass,
|
className: entry.class,
|
||||||
runtime: runtimeChoice.runtime,
|
runtime: runtimeChoice.runtime,
|
||||||
personaLayer: resolved.layer,
|
personaLayer: resolved.layer,
|
||||||
};
|
};
|
||||||
if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint;
|
if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint;
|
||||||
if (isFloor || isLead) seat.persistentPersona = true;
|
if (isFloor || isLead) seat.persistentPersona = true;
|
||||||
if (!isFloor && !isLead) seat.resetBetweenTasks = true;
|
if (!isFloor && !isLead) seat.resetBetweenTasks = true;
|
||||||
if (canonicalEntry.reportsTo) seat.reportsTo = canonicalEntry.reportsTo;
|
if (entry.reportsTo) seat.reportsTo = entry.reportsTo;
|
||||||
seats.push(seat);
|
seats.push(seat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +279,13 @@ export async function validateProfileForProvision(
|
|||||||
opts: ProvisionOptions,
|
opts: ProvisionOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||||
await resolveProfilePersonas(profile, rolesDir, overrideDir);
|
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
|
||||||
|
const problems = validateProfile(profile, validClasses);
|
||||||
|
if (problems.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Profile "${profile.id}" is invalid; cannot provision:\n - ${problems.join('\n - ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
@@ -82,14 +82,10 @@ describe('registerFleetCommand', () => {
|
|||||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||||
'add',
|
'add',
|
||||||
'backlog',
|
'backlog',
|
||||||
'create',
|
|
||||||
'delete',
|
|
||||||
'get',
|
|
||||||
'init',
|
'init',
|
||||||
'install',
|
'install',
|
||||||
'install-systemd',
|
'install-systemd',
|
||||||
'persona',
|
'persona',
|
||||||
'plan',
|
|
||||||
'profile',
|
'profile',
|
||||||
'provision',
|
'provision',
|
||||||
'ps',
|
'ps',
|
||||||
@@ -98,7 +94,6 @@ describe('registerFleetCommand', () => {
|
|||||||
'start',
|
'start',
|
||||||
'status',
|
'status',
|
||||||
'stop',
|
'stop',
|
||||||
'update',
|
|
||||||
'verify',
|
'verify',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -289,8 +284,6 @@ describe('fleet roster parsing', () => {
|
|||||||
'MOSAIC_AGENT_CLASS=implementer',
|
'MOSAIC_AGENT_CLASS=implementer',
|
||||||
'MOSAIC_AGENT_RUNTIME=codex',
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
'MOSAIC_AGENT_MODEL=',
|
'MOSAIC_AGENT_MODEL=',
|
||||||
'MOSAIC_AGENT_REASONING=',
|
|
||||||
'MOSAIC_AGENT_TOOL_POLICY=',
|
|
||||||
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
||||||
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||||
'',
|
'',
|
||||||
@@ -332,38 +325,57 @@ describe('fleet roster parsing', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects legacy command overrides instead of merging them into generated authority', () => {
|
it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => {
|
||||||
const generated = generateAgentEnv(
|
const generated = [
|
||||||
{
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
version: 1,
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
transport: 'tmux',
|
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
||||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||||
defaults: { workingDirectory: '/srv/new' },
|
'',
|
||||||
runtimes: {},
|
].join('\n');
|
||||||
agents: [],
|
const existing = [
|
||||||
},
|
'MOSAIC_AGENT_NAME=old-name',
|
||||||
{ name: 'coder0', className: 'code', runtime: 'codex' },
|
'MOSAIC_AGENT_RUNTIME=old-runtime',
|
||||||
);
|
'MOSAIC_AGENT_WORKDIR=/srv/old',
|
||||||
|
'MOSAIC_TMUX_SOCKET=old-socket',
|
||||||
|
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
||||||
|
'# site note',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
expect((): string => mergeAgentEnv(generated, 'MOSAIC_AGENT_COMMAND=legacy-command\n')).toThrow(
|
expect(mergeAgentEnv(generated, existing)).toBe(
|
||||||
/MOSAIC_AGENT_COMMAND/,
|
[
|
||||||
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
|
'MOSAIC_AGENT_WORKDIR=/srv/new',
|
||||||
|
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
||||||
|
'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
|
||||||
|
'# site note',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not merge a valid local value into the generated projection', () => {
|
it('updates (does not duplicate) MOSAIC_AGENT_CLASS on re-launch', () => {
|
||||||
const generated = generateAgentEnv(
|
const generated = [
|
||||||
{
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
version: 1,
|
'MOSAIC_AGENT_CLASS=orchestrator',
|
||||||
transport: 'tmux',
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
|
'',
|
||||||
defaults: { workingDirectory: '/srv/new' },
|
].join('\n');
|
||||||
runtimes: {},
|
const existing = [
|
||||||
agents: [],
|
'MOSAIC_AGENT_NAME=coder0',
|
||||||
},
|
'MOSAIC_AGENT_CLASS=worker',
|
||||||
{ name: 'coder0', className: 'code', runtime: 'codex' },
|
'MOSAIC_AGENT_RUNTIME=codex',
|
||||||
);
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
expect(mergeAgentEnv(generated, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n')).toBe(generated);
|
const merged = mergeAgentEnv(generated, existing);
|
||||||
|
// mergeAgentEnv keys by VAR name, so the regenerated CLASS wins and there is
|
||||||
|
// exactly one MOSAIC_AGENT_CLASS line (no stale worker value left behind).
|
||||||
|
expect(merged).toContain('MOSAIC_AGENT_CLASS=orchestrator');
|
||||||
|
expect(merged).not.toContain('MOSAIC_AGENT_CLASS=worker');
|
||||||
|
expect(merged.match(/^MOSAIC_AGENT_CLASS=/gm)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unknown roster fields instead of silently defaulting', async () => {
|
it('rejects unknown roster fields instead of silently defaulting', async () => {
|
||||||
@@ -1117,92 +1129,6 @@ describe('fleet command construction', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates private managed directories and roster/projection files through fresh init and install under umask 022', async () => {
|
|
||||||
const originalHome = process.env.HOME;
|
|
||||||
const originalMosaicHome = process.env.MOSAIC_HOME;
|
|
||||||
const originalUmask = process.umask(0o022);
|
|
||||||
const home = await tempDir();
|
|
||||||
process.env.HOME = home;
|
|
||||||
delete process.env.MOSAIC_HOME;
|
|
||||||
const mosaicHome = join(home, '.config', 'mosaic');
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'minimal', '--write']),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
await expect(
|
|
||||||
program.parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
|
|
||||||
for (const path of [
|
|
||||||
mosaicHome,
|
|
||||||
join(mosaicHome, 'fleet'),
|
|
||||||
join(mosaicHome, 'fleet', 'agents'),
|
|
||||||
]) {
|
|
||||||
expect((await stat(path)).mode & 0o777).toBe(0o700);
|
|
||||||
}
|
|
||||||
expect((await stat(join(mosaicHome, 'fleet', 'roster.yaml'))).mode & 0o777).toBe(0o600);
|
|
||||||
expect(
|
|
||||||
(await stat(join(mosaicHome, 'fleet', 'agents', 'canary-pi.env.generated'))).mode & 0o777,
|
|
||||||
).toBe(0o600);
|
|
||||||
} finally {
|
|
||||||
process.umask(originalUmask);
|
|
||||||
if (originalHome === undefined) delete process.env.HOME;
|
|
||||||
else process.env.HOME = originalHome;
|
|
||||||
if (originalMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
|
||||||
else process.env.MOSAIC_HOME = originalMosaicHome;
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates a private real projection directory and private generated files on fresh install', async () => {
|
|
||||||
const originalHome = process.env.HOME;
|
|
||||||
const home = await tempDir();
|
|
||||||
process.env.HOME = home;
|
|
||||||
const mosaicHome = join(home, '.config', 'mosaic');
|
|
||||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
|
||||||
const fleetDir = join(mosaicHome, 'fleet');
|
|
||||||
await mkdir(fleetDir, { recursive: true, mode: 0o700 });
|
|
||||||
await chmod(mosaicHome, 0o700);
|
|
||||||
await chmod(fleetDir, 0o700);
|
|
||||||
await writeFile(
|
|
||||||
join(mosaicHome, 'fleet', 'roster.yaml'),
|
|
||||||
[
|
|
||||||
'version: 1',
|
|
||||||
'transport: tmux',
|
|
||||||
'agents:',
|
|
||||||
' - name: coder0',
|
|
||||||
' runtime: pi',
|
|
||||||
' class: code',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, {
|
|
||||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
|
||||||
mosaicHome,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
program.parseAsync(['node', 'mosaic', 'fleet', 'install', '--no-enable']),
|
|
||||||
).resolves.toBeDefined();
|
|
||||||
|
|
||||||
const directoryMetadata = await lstat(agentEnvDir);
|
|
||||||
expect(directoryMetadata.isDirectory()).toBe(true);
|
|
||||||
expect(directoryMetadata.isSymbolicLink()).toBe(false);
|
|
||||||
expect(directoryMetadata.mode & 0o777).toBe(0o700);
|
|
||||||
expect((await stat(join(agentEnvDir, 'coder0.env.generated'))).mode & 0o777).toBe(0o600);
|
|
||||||
} finally {
|
|
||||||
if (originalHome === undefined) delete process.env.HOME;
|
|
||||||
else process.env.HOME = originalHome;
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['start', 'stop', 'restart', 'status'] as const)(
|
it.each(['start', 'stop', 'restart', 'status'] as const)(
|
||||||
'rejects single-agent %s for agents outside the roster',
|
'rejects single-agent %s for agents outside the roster',
|
||||||
async (action) => {
|
async (action) => {
|
||||||
@@ -1761,10 +1687,8 @@ describe('fleet ps — drift detection', () => {
|
|||||||
describe('fleet-polish bundle — boot-survival symmetry', () => {
|
describe('fleet-polish bundle — boot-survival symmetry', () => {
|
||||||
async function rosterHome(agents: string): Promise<string> {
|
async function rosterHome(agents: string): Promise<string> {
|
||||||
const home = await tempDir();
|
const home = await tempDir();
|
||||||
const fleetDir = join(home, 'fleet');
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
await mkdir(fleetDir, { recursive: true, mode: 0o700 });
|
await writeFile(join(home, 'fleet', 'roster.yaml'), agents);
|
||||||
await chmod(fleetDir, 0o700);
|
|
||||||
await writeFile(join(fleetDir, 'roster.yaml'), agents);
|
|
||||||
return home;
|
return home;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3535,11 +3459,7 @@ describe('fleet add command', () => {
|
|||||||
|
|
||||||
async function makeHome(agents = ['orchestrator']): Promise<string> {
|
async function makeHome(agents = ['orchestrator']): Promise<string> {
|
||||||
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-add-'));
|
const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-add-'));
|
||||||
const fleetDir = join(dir, 'fleet');
|
await mkdir(join(dir, 'fleet', 'agents'), { recursive: true });
|
||||||
const agentEnvDir = join(fleetDir, 'agents');
|
|
||||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
||||||
await chmod(fleetDir, 0o700);
|
|
||||||
await chmod(agentEnvDir, 0o700);
|
|
||||||
const agentLines = agents.map((name) => {
|
const agentLines = agents.map((name) => {
|
||||||
const cls = name === 'orchestrator' ? 'orchestrator' : 'worker';
|
const cls = name === 'orchestrator' ? 'orchestrator' : 'worker';
|
||||||
return ` - name: ${name}\n runtime: claude\n class: ${cls}`;
|
return ` - name: ${name}\n runtime: claude\n class: ${cls}`;
|
||||||
@@ -3573,110 +3493,11 @@ describe('fleet add command', () => {
|
|||||||
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
|
const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
|
||||||
expect(roster.agents.map((a) => a.name)).toContain('coder0');
|
expect(roster.agents.map((a) => a.name)).toContain('coder0');
|
||||||
|
|
||||||
const envContent = await readFile(
|
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
|
||||||
join(home, 'fleet', 'agents', 'coder0.env.generated'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||||
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unprojectable dogfood runtime without mutating roster or projection state', async () => {
|
|
||||||
home = await makeHome();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const agentEnvDir = join(home, 'fleet', 'agents');
|
|
||||||
const trackedPaths = [
|
|
||||||
rosterPath,
|
|
||||||
join(agentEnvDir, 'dogfood.env.generated'),
|
|
||||||
join(agentEnvDir, 'dogfood.env.local'),
|
|
||||||
join(agentEnvDir, 'dogfood.env.quarantine'),
|
|
||||||
];
|
|
||||||
await writeFile(trackedPaths[1]!, 'generated-before\n');
|
|
||||||
await writeFile(trackedPaths[2]!, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n');
|
|
||||||
await writeFile(trackedPaths[3]!, 'quarantine-before\n');
|
|
||||||
const beforeState = new Map<string, Buffer>();
|
|
||||||
for (const path of trackedPaths) beforeState.set(path, await readFile(path));
|
|
||||||
|
|
||||||
const calls: string[][] = [];
|
|
||||||
const runner: CommandRunner = async (command, args) => {
|
|
||||||
calls.push([command, ...args]);
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
|
||||||
};
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'add',
|
|
||||||
'dogfood',
|
|
||||||
'--runtime',
|
|
||||||
'dogfood',
|
|
||||||
'--class',
|
|
||||||
'worker',
|
|
||||||
'--no-start',
|
|
||||||
]),
|
|
||||||
).rejects.toThrow(/runtime/i);
|
|
||||||
|
|
||||||
for (const path of trackedPaths) {
|
|
||||||
expect(await readFile(path)).toEqual(beforeState.get(path));
|
|
||||||
}
|
|
||||||
expect(calls).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects supported add with a deterministic quarantine conflict before roster persistence', async () => {
|
|
||||||
home = await makeHome();
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const agentEnvDir = join(home, 'fleet', 'agents');
|
|
||||||
const trackedPaths = [
|
|
||||||
rosterPath,
|
|
||||||
join(agentEnvDir, 'coder1.env.generated'),
|
|
||||||
join(agentEnvDir, 'coder1.env.local'),
|
|
||||||
join(agentEnvDir, 'coder1.env'),
|
|
||||||
join(agentEnvDir, 'coder1.env.quarantine'),
|
|
||||||
];
|
|
||||||
await writeFile(trackedPaths[1]!, 'generated-before\n', { mode: 0o600 });
|
|
||||||
await writeFile(trackedPaths[2]!, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n', { mode: 0o600 });
|
|
||||||
await writeFile(trackedPaths[3]!, 'MOSAIC_AGENT_COMMAND=must-be-quarantined\n', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
await writeFile(trackedPaths[4]!, 'quarantine-before\n', { mode: 0o600 });
|
|
||||||
const beforeState = new Map<string, Buffer>();
|
|
||||||
for (const path of trackedPaths) beforeState.set(path, await readFile(path));
|
|
||||||
|
|
||||||
const calls: string[][] = [];
|
|
||||||
const runner: CommandRunner = async (command, args) => {
|
|
||||||
calls.push([command, ...args]);
|
|
||||||
return { stdout: '', stderr: '', exitCode: 0 };
|
|
||||||
};
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
program.parseAsync([
|
|
||||||
'node',
|
|
||||||
'mosaic',
|
|
||||||
'fleet',
|
|
||||||
'add',
|
|
||||||
'coder1',
|
|
||||||
'--runtime',
|
|
||||||
'codex',
|
|
||||||
'--class',
|
|
||||||
'worker',
|
|
||||||
'--no-start',
|
|
||||||
]),
|
|
||||||
).rejects.toThrow('quarantine-exists');
|
|
||||||
|
|
||||||
for (const path of trackedPaths) {
|
|
||||||
expect(await readFile(path)).toEqual(beforeState.get(path));
|
|
||||||
}
|
|
||||||
expect(calls).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('--no-start skips the start command', async () => {
|
it('--no-start skips the start command', async () => {
|
||||||
home = await makeHome();
|
home = await makeHome();
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
@@ -3838,10 +3659,7 @@ describe('fleet remove command', () => {
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
);
|
);
|
||||||
// Create env and heartbeat files for coder0
|
// Create env and heartbeat files for coder0
|
||||||
await writeFile(
|
await writeFile(join(dir, 'fleet', 'agents', 'coder0.env'), 'MOSAIC_AGENT_NAME=coder0\n');
|
||||||
join(dir, 'fleet', 'agents', 'coder0.env.generated'),
|
|
||||||
'MOSAIC_AGENT_NAME=coder0\n',
|
|
||||||
);
|
|
||||||
await writeFile(join(dir, 'fleet', 'run', 'coder0.hb'), 'ts=2026-01-01T00:00:00.000Z\n');
|
await writeFile(join(dir, 'fleet', 'run', 'coder0.hb'), 'ts=2026-01-01T00:00:00.000Z\n');
|
||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
@@ -3920,15 +3738,12 @@ describe('fleet remove command', () => {
|
|||||||
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0', '--keep-files']);
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0', '--keep-files']);
|
||||||
|
|
||||||
// Generated projection should still exist.
|
// Env file should still exist
|
||||||
const envContent = await readFile(
|
const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
|
||||||
join(home, 'fleet', 'agents', 'coder0.env.generated'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generated projection is removed by default (no --keep-files)', async () => {
|
it('env file is removed by default (no --keep-files)', async () => {
|
||||||
home = await makeHome();
|
home = await makeHome();
|
||||||
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
|
const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -3937,9 +3752,7 @@ describe('fleet remove command', () => {
|
|||||||
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
|
||||||
|
|
||||||
await expect(
|
await expect(readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8')).rejects.toThrow();
|
||||||
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removing the sole orchestrator throws with a clear error about the guard', async () => {
|
it('removing the sole orchestrator throws with a clear error about the guard', async () => {
|
||||||
|
|||||||
@@ -18,21 +18,7 @@ import { spawn } from 'node:child_process';
|
|||||||
import * as readline from 'node:readline';
|
import * as readline from 'node:readline';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import {
|
|
||||||
registerFleetAgentCrudCommands,
|
|
||||||
type FleetAgentCrudCommandDeps,
|
|
||||||
} from './fleet-agent-crud-command.js';
|
|
||||||
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
||||||
import {
|
|
||||||
applyPreparedAgentEnvironmentProjection,
|
|
||||||
ensureFleetHolderIdentity,
|
|
||||||
GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES,
|
|
||||||
parseAgentEnvironment,
|
|
||||||
prepareAgentEnvironmentProjection,
|
|
||||||
renderGeneratedAgentEnvironment,
|
|
||||||
writeAgentEnvironmentProjection,
|
|
||||||
writeManagedFleetRoster,
|
|
||||||
} from '../fleet/generated-env-boundary.js';
|
|
||||||
import { registerFleetBacklogCommand } from './fleet-backlog.js';
|
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';
|
||||||
@@ -77,7 +63,6 @@ export interface FleetCommandDeps {
|
|||||||
* Tests stub this to simulate interactive or non-interactive environments.
|
* Tests stub this to simulate interactive or non-interactive environments.
|
||||||
*/
|
*/
|
||||||
isStdinTTY?: boolean;
|
isStdinTTY?: boolean;
|
||||||
projectionApplier?: FleetAgentCrudCommandDeps['projectionApplier'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawFleetRoster {
|
interface RawFleetRoster {
|
||||||
@@ -524,35 +509,50 @@ export async function generateNorthStarMarkdown(repoRoot?: string): Promise<stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string {
|
export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string {
|
||||||
return renderGeneratedAgentEnvironment(generateAgentEnvValues(roster, agent));
|
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||||
|
return [
|
||||||
|
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
|
||||||
|
// Per-agent class → start-agent-session.sh / launcher reads this to inject the
|
||||||
|
// matching persona contract for the agent's class (default `worker`).
|
||||||
|
`MOSAIC_AGENT_CLASS=${shellEnvValue(agent.className)}`,
|
||||||
|
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
|
||||||
|
// Per-agent model hint → start-agent-session.sh appends `--model <hint>` to
|
||||||
|
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
|
||||||
|
// openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint.
|
||||||
|
`MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`,
|
||||||
|
...(agent.reasoningLevel !== undefined
|
||||||
|
? [`MOSAIC_AGENT_REASONING=${shellEnvValue(agent.reasoningLevel)}`]
|
||||||
|
: []),
|
||||||
|
...(agent.toolPolicy !== undefined
|
||||||
|
? [`MOSAIC_AGENT_TOOL_POLICY=${shellEnvValue(agent.toolPolicy)}`]
|
||||||
|
: []),
|
||||||
|
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
||||||
|
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compatibility shim for callers that previously merged site-owned data into a
|
|
||||||
* generated file. Local data is validated but never appended to the generated
|
|
||||||
* projection, preventing a local override from becoming hidden authority.
|
|
||||||
*/
|
|
||||||
export function mergeAgentEnv(generatedEnv: string, existingEnv?: string): string {
|
export function mergeAgentEnv(generatedEnv: string, existingEnv?: string): string {
|
||||||
parseAgentEnvironment(generatedEnv, 'generated');
|
if (!existingEnv?.trim()) {
|
||||||
if (existingEnv?.trim()) parseAgentEnvironment(existingEnv, 'local');
|
|
||||||
return generatedEnv;
|
return generatedEnv;
|
||||||
}
|
}
|
||||||
|
const generatedKeys = new Set(
|
||||||
function generateAgentEnvValues(
|
generatedEnv
|
||||||
roster: FleetRoster,
|
.split('\n')
|
||||||
agent: FleetAgent,
|
.map((line) => line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1])
|
||||||
): Readonly<Record<string, string>> {
|
.filter((key): key is string => key !== undefined),
|
||||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
);
|
||||||
return {
|
const preservedLines = existingEnv.split('\n').filter((line) => {
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
if (!line.trim()) {
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
return false;
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
}
|
||||||
MOSAIC_AGENT_MODEL: agent.modelHint ?? '',
|
const key = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=/)?.[1];
|
||||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel ?? '',
|
return key === undefined || !generatedKeys.has(key);
|
||||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy ?? '',
|
});
|
||||||
MOSAIC_AGENT_WORKDIR: expandHome(workingDirectory),
|
if (preservedLines.length === 0) {
|
||||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
return generatedEnv;
|
||||||
};
|
}
|
||||||
|
return [generatedEnv.trimEnd(), ...preservedLines, ''].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
|
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
|
||||||
@@ -1544,12 +1544,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
`Fleet roster already exists: ${destination}. Re-run with --force to overwrite.`,
|
`Fleet roster already exists: ${destination}. Re-run with --force to overwrite.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (resolve(destination) === resolve(activePaths.rosterPath)) {
|
|
||||||
await writeManagedFleetRoster(activePaths.mosaicHome, destination, content);
|
|
||||||
} else {
|
|
||||||
await mkdir(dirname(destination), { recursive: true });
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
await writeFile(destination, content);
|
await writeFile(destination, content);
|
||||||
}
|
|
||||||
|
|
||||||
// Guarantee the two-agent floor: exactly one orchestrator AND at least
|
// Guarantee the two-agent floor: exactly one orchestrator AND at least
|
||||||
// one enhancer for every profile except the sanctioned no-orchestrator
|
// one enhancer for every profile except the sanctioned no-orchestrator
|
||||||
@@ -1952,17 +1948,15 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updatedRoster = addAgentToRoster(roster, newAgent);
|
const updatedRoster = addAgentToRoster(roster, newAgent);
|
||||||
const generated = generateAgentEnvValues(updatedRoster, newAgent);
|
|
||||||
// Preflight every deterministic projection, local, legacy, quarantine,
|
|
||||||
// and managed-directory condition before roster authority changes.
|
|
||||||
const preparedProjection = await prepareAgentEnvironmentProjection({
|
|
||||||
mosaicHome: activePaths.mosaicHome,
|
|
||||||
agentEnvDir: activePaths.agentEnvDir,
|
|
||||||
agentName: name,
|
|
||||||
generated,
|
|
||||||
});
|
|
||||||
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
||||||
await applyPreparedAgentEnvironmentProjection(preparedProjection);
|
|
||||||
|
const envPath = join(activePaths.agentEnvDir, `${name}.env`);
|
||||||
|
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined;
|
||||||
|
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
envPath,
|
||||||
|
mergeAgentEnv(generateAgentEnv(updatedRoster, newAgent), existingEnv),
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
|
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
|
||||||
|
|
||||||
@@ -2043,11 +2037,10 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
// Write updated roster
|
// Write updated roster
|
||||||
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
||||||
|
|
||||||
// Delete the non-authoritative generated projection and heartbeat files
|
// Delete env and heartbeat files (best-effort, non-fatal)
|
||||||
// (best-effort, non-fatal). Operator local/quarantine data is retained.
|
|
||||||
if (!opts.keepFiles) {
|
if (!opts.keepFiles) {
|
||||||
try {
|
try {
|
||||||
await unlink(join(activePaths.agentEnvDir, `${name}.env.generated`));
|
await unlink(join(activePaths.agentEnvDir, `${name}.env`));
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort
|
// best-effort
|
||||||
}
|
}
|
||||||
@@ -2079,10 +2072,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);
|
||||||
|
|
||||||
// Roster-v2 desired-state mutations belong directly to the fleet control
|
|
||||||
// plane; they do not share the root `mosaic agent` gateway-backed surface.
|
|
||||||
registerFleetAgentCrudCommands(cmd, deps);
|
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2339,17 +2328,16 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||||
const roster = await loadRosterForCommand(cmd);
|
const roster = await loadRosterForCommand(cmd);
|
||||||
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
|
||||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||||
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
||||||
|
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||||
|
|
||||||
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||||
const startInteractionServicePath = join(
|
const startInteractionServicePath = join(
|
||||||
activePaths.fleetToolsDir,
|
activePaths.fleetToolsDir,
|
||||||
'start-interaction-service.sh',
|
'start-interaction-service.sh',
|
||||||
);
|
);
|
||||||
const startTmuxHolderPath = join(activePaths.fleetToolsDir, 'start-tmux-holder.sh');
|
|
||||||
const printInteractionPolicyPath = join(
|
const printInteractionPolicyPath = join(
|
||||||
activePaths.fleetToolsDir,
|
activePaths.fleetToolsDir,
|
||||||
'print-interaction-effective-policy.sh',
|
'print-interaction-effective-policy.sh',
|
||||||
@@ -2359,7 +2347,6 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
const executableToolPaths = [
|
const executableToolPaths = [
|
||||||
startAgentSessionPath,
|
startAgentSessionPath,
|
||||||
startInteractionServicePath,
|
startInteractionServicePath,
|
||||||
startTmuxHolderPath,
|
|
||||||
printInteractionPolicyPath,
|
printInteractionPolicyPath,
|
||||||
sendMessagePath,
|
sendMessagePath,
|
||||||
agentSendPath,
|
agentSendPath,
|
||||||
@@ -2372,10 +2359,6 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
||||||
startInteractionServicePath,
|
startInteractionServicePath,
|
||||||
);
|
);
|
||||||
await copyFile(
|
|
||||||
join(frameworkRoot, 'tools', 'fleet', 'start-tmux-holder.sh'),
|
|
||||||
startTmuxHolderPath,
|
|
||||||
);
|
|
||||||
await copyFile(
|
await copyFile(
|
||||||
join(frameworkRoot, 'tools', 'fleet', 'print-interaction-effective-policy.sh'),
|
join(frameworkRoot, 'tools', 'fleet', 'print-interaction-effective-policy.sh'),
|
||||||
printInteractionPolicyPath,
|
printInteractionPolicyPath,
|
||||||
@@ -2399,12 +2382,9 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const agent of roster.agents) {
|
for (const agent of roster.agents) {
|
||||||
await writeAgentEnvironmentProjection({
|
const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`);
|
||||||
mosaicHome: activePaths.mosaicHome,
|
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined;
|
||||||
agentEnvDir: activePaths.agentEnvDir,
|
await writeFile(envPath, mergeAgentEnv(generateAgentEnv(roster, agent), existingEnv));
|
||||||
agentName: agent.name,
|
|
||||||
generated: generateAgentEnvValues(roster, agent),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
|
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
|
||||||
@@ -2676,6 +2656,19 @@ function expandHome(path: string): string {
|
|||||||
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shellEnvValue(value: string): string {
|
||||||
|
// Empty ⇒ a bare `VAR=` (unambiguous empty in a systemd EnvironmentFile and
|
||||||
|
// when shell-sourced). Quoting it as '' risks a literal two-char value (e.g.
|
||||||
|
// a tmux socket named "''"), which would defeat the default-socket behavior.
|
||||||
|
if (value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
for (const agentName of agentNames) {
|
for (const agentName of agentNames) {
|
||||||
@@ -2777,8 +2770,13 @@ export function countEnhancers(roster: FleetRoster): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Valid runtime identifiers for fleet agents. */
|
/** Valid runtime identifiers for fleet agents. */
|
||||||
/** Runtime launchers accepted by `fleet add`; shared with the generated projection validator. */
|
export const VALID_FLEET_RUNTIMES: readonly string[] = [
|
||||||
export const VALID_FLEET_RUNTIMES: readonly string[] = GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES;
|
'pi',
|
||||||
|
'claude',
|
||||||
|
'codex',
|
||||||
|
'opencode',
|
||||||
|
'dogfood',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a new agent to a fleet roster (immutable — returns a new FleetRoster).
|
* Add a new agent to a fleet roster (immutable — returns a new FleetRoster).
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { dirname, join, resolve } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
SHIPPED_FLEET_ARTIFACT_DISPOSITIONS,
|
|
||||||
validateShippedFleetArtifactDispositions,
|
|
||||||
} from './example-profile-dispositions.js';
|
|
||||||
|
|
||||||
const frameworkFleet = resolve(
|
|
||||||
dirname(fileURLToPath(import.meta.url)),
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'framework',
|
|
||||||
'fleet',
|
|
||||||
);
|
|
||||||
|
|
||||||
const EXPECTED_DISPOSITIONS = [
|
|
||||||
'examples/coding.yaml:v1-fixture',
|
|
||||||
'examples/general.yaml:v1-fixture',
|
|
||||||
'examples/hybrid.yaml:v1-fixture',
|
|
||||||
'examples/local-canary.yaml:v1-fixture',
|
|
||||||
'examples/minimal.yaml:v1-fixture',
|
|
||||||
'examples/operator-interaction.yaml:v1-fixture',
|
|
||||||
'examples/research.yaml:v1-fixture',
|
|
||||||
'profiles/business.yaml:canonical-profile',
|
|
||||||
'profiles/marketing.yaml:canonical-profile',
|
|
||||||
'profiles/personal-assistant.yaml:canonical-profile',
|
|
||||||
'profiles/research.yaml:canonical-profile',
|
|
||||||
'profiles/software-delivery.yaml:canonical-profile',
|
|
||||||
'services/operator-interaction.yaml:canonical-service-policy',
|
|
||||||
];
|
|
||||||
|
|
||||||
const declared = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(
|
|
||||||
({ path, disposition }): string => `${path}:${disposition}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
describe('shipped fleet example/profile/service disposition validation', (): void => {
|
|
||||||
it('enumerates every inventory artifact with an explicit executable disposition', (): void => {
|
|
||||||
expect(declared).toEqual(EXPECTED_DISPOSITIONS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validates every shipped artifact through its declared v1 or canonical path', async (): Promise<void> => {
|
|
||||||
const results = await validateShippedFleetArtifactDispositions({ frameworkFleet });
|
|
||||||
|
|
||||||
expect(results.map(({ path, disposition }): string => `${path}:${disposition}`)).toEqual(
|
|
||||||
EXPECTED_DISPOSITIONS,
|
|
||||||
);
|
|
||||||
expect(results.filter(({ disposition }): boolean => disposition === 'v1-fixture')).toHaveLength(
|
|
||||||
7,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
results.filter(({ disposition }): boolean => disposition === 'canonical-profile'),
|
|
||||||
).toHaveLength(5);
|
|
||||||
expect(
|
|
||||||
results.find(({ path }): boolean => path === 'services/operator-interaction.yaml'),
|
|
||||||
).toMatchObject({
|
|
||||||
disposition: 'canonical-service-policy',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let temporaryFleet: string | undefined;
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (temporaryFleet) await rm(temporaryFleet, { recursive: true, force: true });
|
|
||||||
temporaryFleet = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when a shipped artifact lacks a declared disposition', async (): Promise<void> => {
|
|
||||||
temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-'));
|
|
||||||
await cp(frameworkFleet, temporaryFleet, { recursive: true });
|
|
||||||
await writeFile(join(temporaryFleet, 'examples', 'undeclared.yaml'), 'version: 1\n');
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }),
|
|
||||||
).rejects.toThrow(/undeclared shipped fleet artifact.*examples\/undeclared.yaml/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a v1 fixture when its explicit version declaration is removed', async (): Promise<void> => {
|
|
||||||
temporaryFleet = await mkdtemp(join(tmpdir(), 'mosaic-dispositions-'));
|
|
||||||
await cp(frameworkFleet, temporaryFleet, { recursive: true });
|
|
||||||
const fixturePath = join(temporaryFleet, 'examples', 'coding.yaml');
|
|
||||||
const fixture = await readFile(fixturePath, 'utf8');
|
|
||||||
await writeFile(fixturePath, fixture.replace(/^version: 1\n/, ''));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
validateShippedFleetArtifactDispositions({ frameworkFleet: temporaryFleet }),
|
|
||||||
).rejects.toThrow(/Fleet roster version must be 1/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { readdir } from 'node:fs/promises';
|
|
||||||
import { basename, join } from 'node:path';
|
|
||||||
import { loadFleetRoster } from '../commands/fleet.js';
|
|
||||||
import { loadProfiles } from '../commands/fleet-profiles.js';
|
|
||||||
import {
|
|
||||||
provisionInteractionService,
|
|
||||||
readInteractionServiceProfile,
|
|
||||||
} from './interaction-service-profile.js';
|
|
||||||
|
|
||||||
export type FleetArtifactDisposition =
|
|
||||||
| 'v1-fixture'
|
|
||||||
| 'canonical-profile'
|
|
||||||
| 'canonical-service-policy';
|
|
||||||
|
|
||||||
export interface ShippedFleetArtifactDisposition {
|
|
||||||
readonly path: string;
|
|
||||||
readonly disposition: FleetArtifactDisposition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidateShippedFleetArtifactDispositionsOptions {
|
|
||||||
readonly frameworkFleet: string;
|
|
||||||
readonly rolesDir?: string;
|
|
||||||
readonly overrideDir?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The M0 inventory in executable form. Every shipped fleet YAML asset is either
|
|
||||||
* a deliberately retained v1 fixture or validated through its canonical loader.
|
|
||||||
*/
|
|
||||||
export const SHIPPED_FLEET_ARTIFACT_DISPOSITIONS: readonly ShippedFleetArtifactDisposition[] = [
|
|
||||||
{ path: 'examples/coding.yaml', disposition: 'v1-fixture' },
|
|
||||||
{ path: 'examples/general.yaml', disposition: 'v1-fixture' },
|
|
||||||
{ path: 'examples/hybrid.yaml', disposition: 'v1-fixture' },
|
|
||||||
{ path: 'examples/local-canary.yaml', disposition: 'v1-fixture' },
|
|
||||||
{ path: 'examples/minimal.yaml', disposition: 'v1-fixture' },
|
|
||||||
{ path: 'examples/operator-interaction.yaml', disposition: 'v1-fixture' },
|
|
||||||
{ path: 'examples/research.yaml', disposition: 'v1-fixture' },
|
|
||||||
{ path: 'profiles/business.yaml', disposition: 'canonical-profile' },
|
|
||||||
{ path: 'profiles/marketing.yaml', disposition: 'canonical-profile' },
|
|
||||||
{ path: 'profiles/personal-assistant.yaml', disposition: 'canonical-profile' },
|
|
||||||
{ path: 'profiles/research.yaml', disposition: 'canonical-profile' },
|
|
||||||
{ path: 'profiles/software-delivery.yaml', disposition: 'canonical-profile' },
|
|
||||||
{ path: 'services/operator-interaction.yaml', disposition: 'canonical-service-policy' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fail closed when a fleet YAML asset is added or removed without a disposition.
|
|
||||||
* This keeps legacy v1 compatibility explicit instead of silently accepting new
|
|
||||||
* unresolved classes outside the shared resolver.
|
|
||||||
*/
|
|
||||||
export async function validateShippedFleetArtifactDispositions(
|
|
||||||
options: ValidateShippedFleetArtifactDispositionsOptions,
|
|
||||||
): Promise<readonly ShippedFleetArtifactDisposition[]> {
|
|
||||||
await assertEveryShippedArtifactIsDeclared(options.frameworkFleet);
|
|
||||||
|
|
||||||
const examples = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
|
||||||
({ disposition }): boolean => disposition === 'v1-fixture',
|
|
||||||
);
|
|
||||||
for (const artifact of examples) {
|
|
||||||
const roster = await loadFleetRoster(join(options.frameworkFleet, artifact.path));
|
|
||||||
if (roster.version !== 1) {
|
|
||||||
throw new Error(`v1 fixture ${artifact.path} must declare version: 1`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const profilesDir = join(options.frameworkFleet, 'profiles');
|
|
||||||
const profiles = await loadProfiles({
|
|
||||||
profilesDir,
|
|
||||||
rolesDir: options.rolesDir ?? join(options.frameworkFleet, 'roles'),
|
|
||||||
overrideDir: options.overrideDir ?? join(options.frameworkFleet, 'roles.local'),
|
|
||||||
});
|
|
||||||
const declaredProfiles = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
|
||||||
({ disposition }): boolean => disposition === 'canonical-profile',
|
|
||||||
).map(({ path }): string => basename(path, '.yaml'));
|
|
||||||
const resolvedProfiles = new Set(profiles.map(({ id }): string => id));
|
|
||||||
for (const profileId of declaredProfiles) {
|
|
||||||
if (!resolvedProfiles.has(profileId)) {
|
|
||||||
throw new Error(`declared canonical profile ${profileId} did not resolve`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const servicePolicies = SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.filter(
|
|
||||||
({ disposition }): boolean => disposition === 'canonical-service-policy',
|
|
||||||
);
|
|
||||||
for (const artifact of servicePolicies) {
|
|
||||||
const serviceProfile = await readInteractionServiceProfile(
|
|
||||||
join(options.frameworkFleet, artifact.path),
|
|
||||||
);
|
|
||||||
provisionInteractionService(serviceProfile, { agentName: 'interaction-example' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return SHIPPED_FLEET_ARTIFACT_DISPOSITIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertEveryShippedArtifactIsDeclared(frameworkFleet: string): Promise<void> {
|
|
||||||
const declared = new Set(SHIPPED_FLEET_ARTIFACT_DISPOSITIONS.map(({ path }): string => path));
|
|
||||||
const shipped = await listShippedFleetArtifactPaths(frameworkFleet);
|
|
||||||
|
|
||||||
for (const path of shipped) {
|
|
||||||
if (!declared.has(path)) {
|
|
||||||
throw new Error(`undeclared shipped fleet artifact: ${path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const path of declared) {
|
|
||||||
if (!shipped.has(path)) {
|
|
||||||
throw new Error(`declared shipped fleet artifact is missing: ${path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listShippedFleetArtifactPaths(frameworkFleet: string): Promise<Set<string>> {
|
|
||||||
const directories = ['examples', 'profiles', 'services'];
|
|
||||||
const paths = new Set<string>();
|
|
||||||
for (const directory of directories) {
|
|
||||||
const files = await readdir(join(frameworkFleet, directory));
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.endsWith('.yaml') || file.endsWith('.yml')) paths.add(`${directory}/${file}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
FleetAgentMutationError,
|
|
||||||
executeFleetAgentMutation,
|
|
||||||
planFleetAgentMutation,
|
|
||||||
type FleetAgentMutationRequest,
|
|
||||||
} from './fleet-agent-crud.js';
|
|
||||||
import { parseRosterV2, renderRosterV2Yaml } from './roster-v2.js';
|
|
||||||
|
|
||||||
const roster = parseRosterV2(`
|
|
||||||
version: 2
|
|
||||||
generation: 7
|
|
||||||
transport: tmux
|
|
||||||
tmux:
|
|
||||||
socket_name: mosaic-fleet
|
|
||||||
holder_session: _holder
|
|
||||||
defaults:
|
|
||||||
working_directory: /srv/mosaic
|
|
||||||
runtime: pi
|
|
||||||
runtimes:
|
|
||||||
pi:
|
|
||||||
reset_command: /new
|
|
||||||
agents:
|
|
||||||
- name: orchestrator
|
|
||||||
alias: Orchestrator
|
|
||||||
class: orchestrator
|
|
||||||
runtime: pi
|
|
||||||
provider: openai
|
|
||||||
model: gpt-5.6-sol
|
|
||||||
reasoning: high
|
|
||||||
tool_policy: orchestrator
|
|
||||||
working_directory: /srv/mosaic
|
|
||||||
persistent_persona: true
|
|
||||||
reset_between_tasks: false
|
|
||||||
lifecycle:
|
|
||||||
enabled: true
|
|
||||||
desired_state: stopped
|
|
||||||
launch:
|
|
||||||
yolo: true
|
|
||||||
`);
|
|
||||||
|
|
||||||
function createRequest(): FleetAgentMutationRequest {
|
|
||||||
return {
|
|
||||||
operation: 'create',
|
|
||||||
expectedGeneration: 7,
|
|
||||||
agent: {
|
|
||||||
name: 'coder0',
|
|
||||||
alias: 'Coder 0',
|
|
||||||
className: 'code',
|
|
||||||
runtime: 'pi',
|
|
||||||
provider: 'openai',
|
|
||||||
model: 'gpt-5.6-sol',
|
|
||||||
reasoning: 'high',
|
|
||||||
toolPolicy: 'code',
|
|
||||||
workingDirectory: '/srv/mosaic',
|
|
||||||
persistentPersona: false,
|
|
||||||
resetBetweenTasks: true,
|
|
||||||
launch: { yolo: true },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (cleanup) await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-fleet-crud-'));
|
|
||||||
const rolesDir = join(cleanup, 'roles');
|
|
||||||
const overrideDir = join(cleanup, 'roles.local');
|
|
||||||
await mkdir(rolesDir, { recursive: true });
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
for (const klass of ['orchestrator', 'code']) {
|
|
||||||
await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`);
|
|
||||||
}
|
|
||||||
return { rolesDir, overrideDir };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('fleet agent CRUD plan', (): void => {
|
|
||||||
it('creates stopped-by-default desired state and a deterministic generation plan', (): void => {
|
|
||||||
const plan = planFleetAgentMutation(roster, createRequest());
|
|
||||||
|
|
||||||
expect(plan).toMatchObject({
|
|
||||||
operation: 'create',
|
|
||||||
currentGeneration: 7,
|
|
||||||
nextGeneration: 8,
|
|
||||||
changed: true,
|
|
||||||
agent: { name: 'coder0', lifecycle: { enabled: true, desiredState: 'stopped' } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a stale generation before proposing effects', (): void => {
|
|
||||||
expect((): void => {
|
|
||||||
planFleetAgentMutation(roster, { ...createRequest(), expectedGeneration: 6 });
|
|
||||||
}).toThrow(FleetAgentMutationError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats an equivalent retry as an idempotent no-op', (): void => {
|
|
||||||
const created = planFleetAgentMutation(roster, createRequest()).roster;
|
|
||||||
const retried = planFleetAgentMutation(created, {
|
|
||||||
...createRequest(),
|
|
||||||
expectedGeneration: created.generation,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(retried).toMatchObject({ changed: false, currentGeneration: 8, nextGeneration: 8 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fleet agent CRUD execution', (): void => {
|
|
||||||
it('keeps roster and projections unchanged for dry-run', async (): Promise<void> => {
|
|
||||||
const dirs = await semanticDirs();
|
|
||||||
const home = join(cleanup!, 'mosaic');
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
|
|
||||||
await writeFile(rosterPath, 'before\n', { mode: 0o600 });
|
|
||||||
|
|
||||||
const result = await executeFleetAgentMutation({
|
|
||||||
roster,
|
|
||||||
request: createRequest(),
|
|
||||||
mosaicHome: home,
|
|
||||||
rosterPath,
|
|
||||||
agentEnvDir: join(home, 'fleet', 'agents'),
|
|
||||||
rolesDir: dirs.rolesDir,
|
|
||||||
overrideDir: dirs.overrideDir,
|
|
||||||
dryRun: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.applied).toBe(false);
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toBe('before\n');
|
|
||||||
await expect(
|
|
||||||
readFile(join(home, 'fleet', 'agents', 'coder0.env.generated'), 'utf8'),
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when another writer owns the mutation lock', async (): Promise<void> => {
|
|
||||||
const dirs = await semanticDirs();
|
|
||||||
const home = join(cleanup!, 'mosaic');
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
|
|
||||||
await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });
|
|
||||||
await writeFile(`${rosterPath}.mutation.lock`, 'owned\n', { mode: 0o600 });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
executeFleetAgentMutation({
|
|
||||||
roster,
|
|
||||||
request: createRequest(),
|
|
||||||
mosaicHome: home,
|
|
||||||
rosterPath,
|
|
||||||
agentEnvDir: join(home, 'fleet', 'agents'),
|
|
||||||
rolesDir: dirs.rolesDir,
|
|
||||||
overrideDir: dirs.overrideDir,
|
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({ code: 'concurrent-mutation' });
|
|
||||||
expect(await readFile(rosterPath, 'utf8')).toBe(renderRosterV2Yaml(roster));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes only the removed agent generated projection when it is stale or absent', async (): Promise<void> => {
|
|
||||||
const dirs = await semanticDirs();
|
|
||||||
const home = join(cleanup!, 'mosaic');
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
const agentEnvDir = join(home, 'fleet', 'agents');
|
|
||||||
const created = planFleetAgentMutation(roster, createRequest()).roster;
|
|
||||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
||||||
await writeFile(rosterPath, renderRosterV2Yaml(created), { mode: 0o600 });
|
|
||||||
await writeFile(join(agentEnvDir, 'coder0.env.local'), 'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await executeFleetAgentMutation({
|
|
||||||
roster: created,
|
|
||||||
request: { operation: 'delete', expectedGeneration: 8, name: 'coder0' },
|
|
||||||
mosaicHome: home,
|
|
||||||
rosterPath,
|
|
||||||
agentEnvDir,
|
|
||||||
rolesDir: dirs.rolesDir,
|
|
||||||
overrideDir: dirs.overrideDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ applied: true, plan: { nextGeneration: 9 } });
|
|
||||||
await expect(readFile(join(agentEnvDir, 'coder0.env.generated'), 'utf8')).rejects.toThrow();
|
|
||||||
expect(await readFile(join(agentEnvDir, 'coder0.env.local'), 'utf8')).toBe(
|
|
||||||
'MOSAIC_RUNTIME_BIN=/usr/bin/pi\n',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns redacted projection recovery after the authoritative roster write', async (): Promise<void> => {
|
|
||||||
const dirs = await semanticDirs();
|
|
||||||
const home = join(cleanup!, 'mosaic');
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
await mkdir(join(home, 'fleet', 'agents'), { recursive: true, mode: 0o700 });
|
|
||||||
await writeFile(rosterPath, renderRosterV2Yaml(roster), { mode: 0o600 });
|
|
||||||
|
|
||||||
const result = await executeFleetAgentMutation({
|
|
||||||
roster,
|
|
||||||
request: createRequest(),
|
|
||||||
mosaicHome: home,
|
|
||||||
rosterPath,
|
|
||||||
agentEnvDir: join(home, 'fleet', 'agents'),
|
|
||||||
rolesDir: dirs.rolesDir,
|
|
||||||
overrideDir: dirs.overrideDir,
|
|
||||||
projectionApplier: async (): Promise<void> => {
|
|
||||||
throw new Error('MOSAIC_AGENT_COMMAND=must-not-leak');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
applied: false,
|
|
||||||
authoritativeRoster: 'committed',
|
|
||||||
projections: 'incomplete',
|
|
||||||
recovery: { code: 'projection-apply-failed', action: 'regenerate-projections-from-roster' },
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(result)).not.toContain('must-not-leak');
|
|
||||||
expect(parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml').generation).toBe(8);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
import { open, readFile, unlink } from 'node:fs/promises';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import {
|
|
||||||
applyPreparedAgentEnvironmentProjection,
|
|
||||||
prepareAgentGeneratedProjectionDeletion,
|
|
||||||
prepareAgentEnvironmentProjection,
|
|
||||||
type PreparedAgentEnvironmentProjection,
|
|
||||||
writeManagedFleetRoster,
|
|
||||||
} from './generated-env-boundary.js';
|
|
||||||
import {
|
|
||||||
parseRosterV2,
|
|
||||||
renderRosterV2Yaml,
|
|
||||||
validateRosterV2Semantics,
|
|
||||||
type FleetRosterV2,
|
|
||||||
type FleetRosterV2Agent,
|
|
||||||
type FleetRosterV2Launch,
|
|
||||||
type FleetRosterV2Lifecycle,
|
|
||||||
type RosterV2ReasoningLevel,
|
|
||||||
type RosterV2RuntimeName,
|
|
||||||
} from './roster-v2.js';
|
|
||||||
|
|
||||||
export type FleetAgentMutationOperation = 'create' | 'update' | 'delete';
|
|
||||||
|
|
||||||
export interface FleetAgentMutationAgent {
|
|
||||||
readonly name: string;
|
|
||||||
readonly alias: string;
|
|
||||||
readonly className: string;
|
|
||||||
readonly runtime: RosterV2RuntimeName;
|
|
||||||
readonly provider: string;
|
|
||||||
readonly model: string;
|
|
||||||
readonly reasoning: RosterV2ReasoningLevel;
|
|
||||||
readonly toolPolicy: string;
|
|
||||||
readonly workingDirectory: string;
|
|
||||||
readonly persistentPersona: boolean;
|
|
||||||
readonly resetBetweenTasks: boolean;
|
|
||||||
readonly launch: FleetRosterV2Launch;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetAgentMutationRequest {
|
|
||||||
readonly operation: FleetAgentMutationOperation;
|
|
||||||
readonly expectedGeneration: number;
|
|
||||||
readonly name?: string;
|
|
||||||
readonly agent?: FleetAgentMutationAgent;
|
|
||||||
readonly persistedStart?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetAgentMutationPlan {
|
|
||||||
readonly operation: FleetAgentMutationOperation;
|
|
||||||
readonly currentGeneration: number;
|
|
||||||
readonly nextGeneration: number;
|
|
||||||
readonly changed: boolean;
|
|
||||||
readonly roster: FleetRosterV2;
|
|
||||||
readonly agent?: FleetRosterV2Agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FleetAgentMutationAuthoritativeRosterState = 'unchanged' | 'committed';
|
|
||||||
export type FleetAgentMutationProjectionState = 'not-applied' | 'complete' | 'incomplete';
|
|
||||||
|
|
||||||
export interface FleetAgentMutationResult {
|
|
||||||
/** True only when every requested roster and projection write completed. */
|
|
||||||
readonly applied: boolean;
|
|
||||||
/** Whether the authoritative roster changed on disk. */
|
|
||||||
readonly authoritativeRoster: FleetAgentMutationAuthoritativeRosterState;
|
|
||||||
/** Whether derived projections were skipped, complete, or require recovery. */
|
|
||||||
readonly projections: FleetAgentMutationProjectionState;
|
|
||||||
readonly plan: FleetAgentMutationPlan;
|
|
||||||
readonly recovery?: FleetAgentMutationRecovery;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetAgentMutationRecovery {
|
|
||||||
readonly code: 'projection-apply-failed';
|
|
||||||
readonly rosterPath: string;
|
|
||||||
readonly action: 'regenerate-projections-from-roster';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FleetAgentMutationOptions {
|
|
||||||
readonly roster: FleetRosterV2;
|
|
||||||
readonly request: FleetAgentMutationRequest;
|
|
||||||
readonly mosaicHome: string;
|
|
||||||
readonly rosterPath: string;
|
|
||||||
readonly agentEnvDir: string;
|
|
||||||
readonly rolesDir: string;
|
|
||||||
readonly overrideDir: string;
|
|
||||||
readonly dryRun?: boolean;
|
|
||||||
readonly projectionApplier?: (prepared: PreparedAgentEnvironmentProjection) => Promise<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FleetAgentMutationError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly code:
|
|
||||||
| 'stale-generation'
|
|
||||||
| 'agent-conflict'
|
|
||||||
| 'agent-not-found'
|
|
||||||
| 'invalid-request'
|
|
||||||
| 'concurrent-mutation'
|
|
||||||
| 'projection-apply-failed',
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = FleetAgentMutationError.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Plans a generation-guarded desired-state mutation without writing any file. */
|
|
||||||
export function planFleetAgentMutation(
|
|
||||||
roster: FleetRosterV2,
|
|
||||||
request: FleetAgentMutationRequest,
|
|
||||||
): FleetAgentMutationPlan {
|
|
||||||
if (request.expectedGeneration !== roster.generation) {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'stale-generation',
|
|
||||||
`Expected roster generation ${request.expectedGeneration}; current generation is ${roster.generation}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.operation === 'create') return planCreate(roster, request);
|
|
||||||
if (request.operation === 'update') return planUpdate(roster, request);
|
|
||||||
return planDelete(roster, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the complete proposed roster and its deterministic projections before
|
|
||||||
* changing the roster authority. The only post-roster write is a derived projection.
|
|
||||||
*/
|
|
||||||
export async function executeFleetAgentMutation(
|
|
||||||
options: FleetAgentMutationOptions,
|
|
||||||
): Promise<FleetAgentMutationResult> {
|
|
||||||
const plan = planFleetAgentMutation(options.roster, options.request);
|
|
||||||
await validateRosterV2Semantics(plan.roster, {
|
|
||||||
rolesDir: options.rolesDir,
|
|
||||||
overrideDir: options.overrideDir,
|
|
||||||
});
|
|
||||||
const prepared = await prepareProjections(plan.roster, options.mosaicHome, options.agentEnvDir);
|
|
||||||
if (plan.operation === 'delete' && plan.agent) {
|
|
||||||
// Delete validates only the exact generated projection. Local overrides,
|
|
||||||
// legacy input, and quarantine records are retained operator state.
|
|
||||||
await prepareAgentGeneratedProjectionDeletion({
|
|
||||||
mosaicHome: options.mosaicHome,
|
|
||||||
agentEnvDir: options.agentEnvDir,
|
|
||||||
agentName: plan.agent.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.dryRun === true || !plan.changed) {
|
|
||||||
return { applied: false, authoritativeRoster: 'unchanged', projections: 'not-applied', plan };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lockPath = `${options.rosterPath}.mutation.lock`;
|
|
||||||
const release = await acquireMutationLock(lockPath);
|
|
||||||
try {
|
|
||||||
const persisted = parseRosterV2(await readFile(options.rosterPath, 'utf8'), 'yaml');
|
|
||||||
if (persisted.generation !== options.roster.generation) {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'stale-generation',
|
|
||||||
`Expected roster generation ${options.roster.generation}; current generation is ${persisted.generation}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeManagedFleetRoster(
|
|
||||||
options.mosaicHome,
|
|
||||||
options.rosterPath,
|
|
||||||
renderRosterV2Yaml(plan.roster),
|
|
||||||
);
|
|
||||||
const apply = options.projectionApplier ?? applyPreparedAgentEnvironmentProjection;
|
|
||||||
try {
|
|
||||||
for (const projection of prepared) await apply(projection);
|
|
||||||
if (plan.operation === 'delete' && plan.agent) {
|
|
||||||
await prepareAgentGeneratedProjectionDeletion({
|
|
||||||
mosaicHome: options.mosaicHome,
|
|
||||||
agentEnvDir: options.agentEnvDir,
|
|
||||||
agentName: plan.agent.name,
|
|
||||||
});
|
|
||||||
await removeGeneratedProjection(options.agentEnvDir, plan.agent.name);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'projection-apply-failed',
|
|
||||||
`Projection application failed after roster write; regenerate projections from ${options.rosterPath}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { applied: true, authoritativeRoster: 'committed', projections: 'complete', plan };
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof FleetAgentMutationError && error.code === 'projection-apply-failed') {
|
|
||||||
return {
|
|
||||||
applied: false,
|
|
||||||
authoritativeRoster: 'committed',
|
|
||||||
projections: 'incomplete',
|
|
||||||
plan,
|
|
||||||
recovery: {
|
|
||||||
code: 'projection-apply-failed',
|
|
||||||
rosterPath: options.rosterPath,
|
|
||||||
action: 'regenerate-projections-from-roster',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function planCreate(
|
|
||||||
roster: FleetRosterV2,
|
|
||||||
request: FleetAgentMutationRequest,
|
|
||||||
): FleetAgentMutationPlan {
|
|
||||||
if (!request.agent)
|
|
||||||
throw new FleetAgentMutationError('invalid-request', 'Create requires an agent.');
|
|
||||||
const existing = roster.agents.find(
|
|
||||||
(agent: FleetRosterV2Agent): boolean => agent.name === request.agent?.name,
|
|
||||||
);
|
|
||||||
const created = toRosterAgent(request.agent, request.persistedStart === true);
|
|
||||||
if (existing) {
|
|
||||||
if (sameAgent(existing, created)) return unchangedPlan(roster, request.operation, existing);
|
|
||||||
throw new FleetAgentMutationError('agent-conflict', `Agent "${created.name}" already exists.`);
|
|
||||||
}
|
|
||||||
return changedPlan(roster, request.operation, [...roster.agents, created], created);
|
|
||||||
}
|
|
||||||
|
|
||||||
function planUpdate(
|
|
||||||
roster: FleetRosterV2,
|
|
||||||
request: FleetAgentMutationRequest,
|
|
||||||
): FleetAgentMutationPlan {
|
|
||||||
if (!request.name || !request.agent) {
|
|
||||||
throw new FleetAgentMutationError('invalid-request', 'Update requires name and agent.');
|
|
||||||
}
|
|
||||||
const existing = roster.agents.find(
|
|
||||||
(agent: FleetRosterV2Agent): boolean => agent.name === request.name,
|
|
||||||
);
|
|
||||||
if (!existing)
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'agent-not-found',
|
|
||||||
`Agent "${request.name}" is not in roster.`,
|
|
||||||
);
|
|
||||||
if (request.agent.name !== request.name) {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'invalid-request',
|
|
||||||
'Agent names are immutable during update.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const updated = {
|
|
||||||
...toRosterAgent(request.agent, existing.lifecycle.desiredState === 'running'),
|
|
||||||
lifecycle: existing.lifecycle,
|
|
||||||
};
|
|
||||||
if (sameAgent(existing, updated)) return unchangedPlan(roster, request.operation, existing);
|
|
||||||
return changedPlan(
|
|
||||||
roster,
|
|
||||||
request.operation,
|
|
||||||
roster.agents.map(
|
|
||||||
(agent: FleetRosterV2Agent): FleetRosterV2Agent =>
|
|
||||||
agent.name === request.name ? updated : agent,
|
|
||||||
),
|
|
||||||
updated,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function planDelete(
|
|
||||||
roster: FleetRosterV2,
|
|
||||||
request: FleetAgentMutationRequest,
|
|
||||||
): FleetAgentMutationPlan {
|
|
||||||
if (!request.name) throw new FleetAgentMutationError('invalid-request', 'Delete requires name.');
|
|
||||||
const existing = roster.agents.find(
|
|
||||||
(agent: FleetRosterV2Agent): boolean => agent.name === request.name,
|
|
||||||
);
|
|
||||||
if (!existing) return unchangedPlan(roster, request.operation);
|
|
||||||
return changedPlan(
|
|
||||||
roster,
|
|
||||||
request.operation,
|
|
||||||
roster.agents.filter((agent: FleetRosterV2Agent): boolean => agent.name !== request.name),
|
|
||||||
existing,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRosterAgent(
|
|
||||||
input: FleetAgentMutationAgent,
|
|
||||||
persistedStart: boolean,
|
|
||||||
): FleetRosterV2Agent {
|
|
||||||
const lifecycle: FleetRosterV2Lifecycle = {
|
|
||||||
enabled: true,
|
|
||||||
desiredState: persistedStart ? 'running' : 'stopped',
|
|
||||||
};
|
|
||||||
return { ...input, lifecycle };
|
|
||||||
}
|
|
||||||
|
|
||||||
function changedPlan(
|
|
||||||
roster: FleetRosterV2,
|
|
||||||
operation: FleetAgentMutationOperation,
|
|
||||||
agents: readonly FleetRosterV2Agent[],
|
|
||||||
agent?: FleetRosterV2Agent,
|
|
||||||
): FleetAgentMutationPlan {
|
|
||||||
const proposed = { ...roster, generation: roster.generation + 1, agents };
|
|
||||||
const normalized = parseRosterV2(renderRosterV2Yaml(proposed), 'yaml');
|
|
||||||
return {
|
|
||||||
operation,
|
|
||||||
currentGeneration: roster.generation,
|
|
||||||
nextGeneration: normalized.generation,
|
|
||||||
changed: true,
|
|
||||||
roster: normalized,
|
|
||||||
...(agent ? { agent } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function unchangedPlan(
|
|
||||||
roster: FleetRosterV2,
|
|
||||||
operation: FleetAgentMutationOperation,
|
|
||||||
agent?: FleetRosterV2Agent,
|
|
||||||
): FleetAgentMutationPlan {
|
|
||||||
return {
|
|
||||||
operation,
|
|
||||||
currentGeneration: roster.generation,
|
|
||||||
nextGeneration: roster.generation,
|
|
||||||
changed: false,
|
|
||||||
roster,
|
|
||||||
...(agent ? { agent } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameAgent(left: FleetRosterV2Agent, right: FleetRosterV2Agent): boolean {
|
|
||||||
return (
|
|
||||||
left.name === right.name &&
|
|
||||||
left.alias === right.alias &&
|
|
||||||
left.className === right.className &&
|
|
||||||
left.runtime === right.runtime &&
|
|
||||||
left.provider === right.provider &&
|
|
||||||
left.model === right.model &&
|
|
||||||
left.reasoning === right.reasoning &&
|
|
||||||
left.toolPolicy === right.toolPolicy &&
|
|
||||||
left.workingDirectory === right.workingDirectory &&
|
|
||||||
left.persistentPersona === right.persistentPersona &&
|
|
||||||
left.resetBetweenTasks === right.resetBetweenTasks &&
|
|
||||||
left.launch.yolo === right.launch.yolo &&
|
|
||||||
left.lifecycle.enabled === right.lifecycle.enabled &&
|
|
||||||
left.lifecycle.desiredState === right.lifecycle.desiredState
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareProjections(
|
|
||||||
roster: FleetRosterV2,
|
|
||||||
mosaicHome: string,
|
|
||||||
agentEnvDir: string,
|
|
||||||
): Promise<readonly PreparedAgentEnvironmentProjection[]> {
|
|
||||||
const projections: PreparedAgentEnvironmentProjection[] = [];
|
|
||||||
for (const agent of roster.agents) {
|
|
||||||
projections.push(
|
|
||||||
await prepareAgentEnvironmentProjection({
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir,
|
|
||||||
agentName: agent.name,
|
|
||||||
generated: generatedValues(roster, agent),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return projections;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatedValues(
|
|
||||||
roster: FleetRosterV2,
|
|
||||||
agent: FleetRosterV2Agent,
|
|
||||||
): Readonly<Record<string, string>> {
|
|
||||||
return {
|
|
||||||
MOSAIC_AGENT_NAME: agent.name,
|
|
||||||
MOSAIC_AGENT_CLASS: agent.className,
|
|
||||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
|
||||||
MOSAIC_AGENT_MODEL: agent.model,
|
|
||||||
MOSAIC_AGENT_REASONING: agent.reasoning,
|
|
||||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
|
||||||
MOSAIC_AGENT_WORKDIR: agent.workingDirectory,
|
|
||||||
MOSAIC_TMUX_SOCKET: roster.tmux.socketName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeGeneratedProjection(agentEnvDir: string, agentName: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await unlink(join(agentEnvDir, `${agentName}.env.generated`));
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (isMissingFile(error)) return;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMissingFile(error: unknown): boolean {
|
|
||||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function acquireMutationLock(lockPath: string): Promise<() => Promise<void>> {
|
|
||||||
try {
|
|
||||||
const handle = await open(lockPath, 'wx', 0o600);
|
|
||||||
await handle.close();
|
|
||||||
} catch {
|
|
||||||
throw new FleetAgentMutationError(
|
|
||||||
'concurrent-mutation',
|
|
||||||
`Another roster mutation is in progress for ${dirname(lockPath)}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return async (): Promise<void> => {
|
|
||||||
await unlink(lockPath).catch((): void => {});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
import {
|
|
||||||
chmod,
|
|
||||||
mkdir,
|
|
||||||
mkdtemp,
|
|
||||||
readFile,
|
|
||||||
rename,
|
|
||||||
rm,
|
|
||||||
stat,
|
|
||||||
symlink,
|
|
||||||
writeFile,
|
|
||||||
} from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
AgentEnvBoundaryError,
|
|
||||||
parseAgentEnvironment,
|
|
||||||
renderGeneratedAgentEnvironment,
|
|
||||||
writeAgentEnvironmentProjection,
|
|
||||||
} from './generated-env-boundary.js';
|
|
||||||
|
|
||||||
const generatedValues = {
|
|
||||||
MOSAIC_AGENT_NAME: 'coder0',
|
|
||||||
MOSAIC_AGENT_CLASS: 'code',
|
|
||||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
|
||||||
MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol',
|
|
||||||
MOSAIC_AGENT_REASONING: 'high',
|
|
||||||
MOSAIC_AGENT_TOOL_POLICY: 'code',
|
|
||||||
MOSAIC_AGENT_WORKDIR: '/srv/mosaic',
|
|
||||||
MOSAIC_TMUX_SOCKET: 'mosaic-fleet',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('generated fleet agent environment boundary', (): void => {
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (cleanup) {
|
|
||||||
await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a deterministic complete generated projection', (): void => {
|
|
||||||
expect(renderGeneratedAgentEnvironment(generatedValues)).toBe(
|
|
||||||
[
|
|
||||||
'MOSAIC_AGENT_NAME=coder0',
|
|
||||||
'MOSAIC_AGENT_CLASS=code',
|
|
||||||
'MOSAIC_AGENT_RUNTIME=pi',
|
|
||||||
'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol',
|
|
||||||
'MOSAIC_AGENT_REASONING=high',
|
|
||||||
'MOSAIC_AGENT_TOOL_POLICY=code',
|
|
||||||
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
|
||||||
'MOSAIC_TMUX_SOCKET=mosaic-fleet',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['generated-key shadowing', 'MOSAIC_AGENT_RUNTIME=codex\n'],
|
|
||||||
['unknown key', 'UNRELATED_SETTING=value\n'],
|
|
||||||
['arbitrary command', 'MOSAIC_AGENT_COMMAND=mosaic yolo codex\n'],
|
|
||||||
['sensitive key', 'MOSAIC_AGENT_TOKEN=do-not-log-me\n'],
|
|
||||||
['malformed syntax', 'export MOSAIC_RUNTIME_BIN=/opt/bin\n'],
|
|
||||||
['duplicate keys', 'MOSAIC_RUNTIME_BIN=/opt/bin\nMOSAIC_RUNTIME_BIN=/other/bin\n'],
|
|
||||||
])('rejects local %s without echoing values', (_name: string, source: string): void => {
|
|
||||||
let error: unknown;
|
|
||||||
try {
|
|
||||||
parseAgentEnvironment(source, 'local');
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
error = caught;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(AgentEnvBoundaryError);
|
|
||||||
expect(String(error)).not.toContain('mosaic yolo codex');
|
|
||||||
expect(String(error)).not.toContain('do-not-log-me');
|
|
||||||
expect(String(error)).toMatch(/key=.*sha256=/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unsafe generated paths before any launch consumer can use them', (): void => {
|
|
||||||
expect((): void => {
|
|
||||||
renderGeneratedAgentEnvironment({
|
|
||||||
...generatedValues,
|
|
||||||
MOSAIC_AGENT_WORKDIR: '../outside',
|
|
||||||
});
|
|
||||||
}).toThrow(AgentEnvBoundaryError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates missing managed directories privately before writing a projection', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
|
||||||
const mosaicHome = join(cleanup, 'mosaic');
|
|
||||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
|
||||||
|
|
||||||
const result = await writeAgentEnvironmentProjection({
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir,
|
|
||||||
agentName: 'coder0',
|
|
||||||
generated: generatedValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const directory of [mosaicHome, join(mosaicHome, 'fleet'), agentEnvDir]) {
|
|
||||||
expect((await stat(directory)).mode & 0o777).toBe(0o700);
|
|
||||||
}
|
|
||||||
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('regenerates desired keys, relocates safe legacy local data, and quarantines forbidden legacy input', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
|
||||||
const mosaicHome = join(cleanup, 'mosaic');
|
|
||||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
|
||||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
||||||
await writeFile(
|
|
||||||
join(agentEnvDir, 'coder0.env'),
|
|
||||||
[
|
|
||||||
'MOSAIC_AGENT_NAME=stale-name',
|
|
||||||
'MOSAIC_AGENT_RUNTIME=codex',
|
|
||||||
'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin',
|
|
||||||
'MOSAIC_AGENT_COMMAND=mosaic yolo codex --dangerous',
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await writeAgentEnvironmentProjection({
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir,
|
|
||||||
agentName: 'coder0',
|
|
||||||
generated: generatedValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await readFile(result.generatedPath, 'utf8')).toBe(
|
|
||||||
renderGeneratedAgentEnvironment(generatedValues),
|
|
||||||
);
|
|
||||||
expect(await readFile(result.localPath, 'utf8')).toBe('MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n');
|
|
||||||
const quarantine = await readFile(result.quarantinePath!, 'utf8');
|
|
||||||
expect(quarantine).toContain('MOSAIC_AGENT_COMMAND=mosaic yolo codex --dangerous');
|
|
||||||
await expect(readFile(join(agentEnvDir, 'coder0.env'), 'utf8')).rejects.toThrow();
|
|
||||||
expect((await stat(result.generatedPath)).mode & 0o077).toBe(0);
|
|
||||||
expect((await stat(result.localPath)).mode & 0o077).toBe(0);
|
|
||||||
expect(result.diagnostics).toEqual([expect.objectContaining({ key: 'MOSAIC_AGENT_COMMAND' })]);
|
|
||||||
expect(JSON.stringify(result.diagnostics)).not.toContain('mosaic yolo codex --dangerous');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed on an existing local override with unsafe permissions', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
|
||||||
const mosaicHome = join(cleanup, 'mosaic');
|
|
||||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
|
||||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
||||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
|
||||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n', { mode: 0o600 });
|
|
||||||
await chmod(localPath, 0o644);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
writeAgentEnvironmentProjection({
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir,
|
|
||||||
agentName: 'coder0',
|
|
||||||
generated: generatedValues,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/permissions/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an existing group/world-accessible projection directory before reading it', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
|
||||||
const mosaicHome = join(cleanup, 'mosaic');
|
|
||||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
|
||||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
|
||||||
const generatedPath = join(agentEnvDir, 'coder0.env.generated');
|
|
||||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
||||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n', { mode: 0o600 });
|
|
||||||
await chmod(agentEnvDir, 0o777);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
writeAgentEnvironmentProjection({
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir,
|
|
||||||
agentName: 'coder0',
|
|
||||||
generated: generatedValues,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/unsafe-permissions/i);
|
|
||||||
|
|
||||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n');
|
|
||||||
await expect(readFile(generatedPath, 'utf8')).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['MOSAIC_HOME', 'symlink'],
|
|
||||||
['MOSAIC_HOME/fleet', 'symlink'],
|
|
||||||
['MOSAIC_HOME/fleet/agents', 'symlink'],
|
|
||||||
['MOSAIC_HOME', 'group/world-writable'],
|
|
||||||
['MOSAIC_HOME/fleet', 'group/world-writable'],
|
|
||||||
['MOSAIC_HOME/fleet/agents', 'group/world-writable'],
|
|
||||||
])(
|
|
||||||
'rejects a %s %s managed ancestor before any projection mutation',
|
|
||||||
async (ancestor: string, hazard: string): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
|
||||||
const mosaicHome = join(cleanup, 'mosaic');
|
|
||||||
const fleetDir = join(mosaicHome, 'fleet');
|
|
||||||
const agentEnvDir = join(fleetDir, 'agents');
|
|
||||||
const generatedPath = join(agentEnvDir, 'coder0.env.generated');
|
|
||||||
const localPath = join(agentEnvDir, 'coder0.env.local');
|
|
||||||
const legacyPath = join(agentEnvDir, 'coder0.env');
|
|
||||||
const quarantinePath = join(agentEnvDir, 'coder0.env.quarantine');
|
|
||||||
const managedPaths: Record<string, string> = {
|
|
||||||
MOSAIC_HOME: mosaicHome,
|
|
||||||
'MOSAIC_HOME/fleet': fleetDir,
|
|
||||||
'MOSAIC_HOME/fleet/agents': agentEnvDir,
|
|
||||||
};
|
|
||||||
const unsafePath = managedPaths[ancestor];
|
|
||||||
if (unsafePath === undefined) throw new Error(`Unknown managed ancestor: ${ancestor}`);
|
|
||||||
|
|
||||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
|
||||||
await chmod(mosaicHome, 0o700);
|
|
||||||
await chmod(fleetDir, 0o700);
|
|
||||||
await chmod(agentEnvDir, 0o700);
|
|
||||||
await writeFile(generatedPath, 'generated-before\n', { mode: 0o600 });
|
|
||||||
await writeFile(localPath, 'MOSAIC_RUNTIME_BIN=/safe/runtime\n', { mode: 0o600 });
|
|
||||||
await writeFile(legacyPath, 'MOSAIC_AGENT_COMMAND=legacy-command\n', { mode: 0o600 });
|
|
||||||
|
|
||||||
if (hazard === 'symlink') {
|
|
||||||
const targetPath = `${unsafePath}-target`;
|
|
||||||
await rename(unsafePath, targetPath);
|
|
||||||
await symlink(targetPath, unsafePath, 'dir');
|
|
||||||
} else {
|
|
||||||
await chmod(unsafePath, 0o777);
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
writeAgentEnvironmentProjection({
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir,
|
|
||||||
agentName: 'coder0',
|
|
||||||
generated: generatedValues,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/unsafe-(directory|permissions)/i);
|
|
||||||
|
|
||||||
await expect(readFile(generatedPath, 'utf8')).resolves.toBe('generated-before\n');
|
|
||||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('MOSAIC_RUNTIME_BIN=/safe/runtime\n');
|
|
||||||
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(
|
|
||||||
'MOSAIC_AGENT_COMMAND=legacy-command\n',
|
|
||||||
);
|
|
||||||
await expect(readFile(quarantinePath, 'utf8')).rejects.toThrow();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('rejects a symlinked projection directory without mutating its target', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
|
||||||
const mosaicHome = join(cleanup, 'mosaic');
|
|
||||||
const targetDirectory = join(cleanup, 'target');
|
|
||||||
const linkedDirectory = join(mosaicHome, 'fleet', 'agents');
|
|
||||||
const legacyPath = join(targetDirectory, 'coder0.env');
|
|
||||||
const generatedPath = join(targetDirectory, 'coder0.env.generated');
|
|
||||||
const localPath = join(targetDirectory, 'coder0.env.local');
|
|
||||||
const quarantinePath = join(targetDirectory, 'coder0.env.quarantine');
|
|
||||||
await mkdir(join(mosaicHome, 'fleet'), { recursive: true, mode: 0o700 });
|
|
||||||
await mkdir(targetDirectory, { mode: 0o700 });
|
|
||||||
await writeFile(legacyPath, 'MOSAIC_AGENT_COMMAND=legacy-command\n', { mode: 0o600 });
|
|
||||||
await writeFile(generatedPath, 'generated-before\n', { mode: 0o600 });
|
|
||||||
await writeFile(localPath, 'local-before\n', { mode: 0o600 });
|
|
||||||
await writeFile(quarantinePath, 'quarantine-before\n', { mode: 0o600 });
|
|
||||||
await symlink(targetDirectory, linkedDirectory, 'dir');
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
writeAgentEnvironmentProjection({
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir: linkedDirectory,
|
|
||||||
agentName: 'coder0',
|
|
||||||
generated: generatedValues,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/unsafe-directory/i);
|
|
||||||
|
|
||||||
await expect(readFile(legacyPath, 'utf8')).resolves.toBe(
|
|
||||||
'MOSAIC_AGENT_COMMAND=legacy-command\n',
|
|
||||||
);
|
|
||||||
await expect(readFile(generatedPath, 'utf8')).resolves.toBe('generated-before\n');
|
|
||||||
await expect(readFile(localPath, 'utf8')).resolves.toBe('local-before\n');
|
|
||||||
await expect(readFile(quarantinePath, 'utf8')).resolves.toBe('quarantine-before\n');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,532 +0,0 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
|
||||||
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
||||||
import { dirname, join, resolve } from 'node:path';
|
|
||||||
|
|
||||||
export type AgentEnvironmentKind = 'generated' | 'local';
|
|
||||||
|
|
||||||
export interface AgentEnvironmentDiagnostic {
|
|
||||||
readonly code: string;
|
|
||||||
readonly key: string;
|
|
||||||
readonly sha256: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentEnvironmentProjectionOptions {
|
|
||||||
readonly mosaicHome: string;
|
|
||||||
readonly agentEnvDir: string;
|
|
||||||
readonly agentName: string;
|
|
||||||
readonly generated: Readonly<Record<string, string>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentGeneratedProjectionDeletionOptions {
|
|
||||||
readonly mosaicHome: string;
|
|
||||||
readonly agentEnvDir: string;
|
|
||||||
readonly agentName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentEnvironmentProjectionResult {
|
|
||||||
readonly generatedPath: string;
|
|
||||||
readonly localPath: string;
|
|
||||||
readonly quarantinePath?: string;
|
|
||||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A projection fully validated without changing managed files. */
|
|
||||||
export interface PreparedAgentEnvironmentProjection {
|
|
||||||
readonly mosaicHome: string;
|
|
||||||
readonly agentEnvDir: string;
|
|
||||||
readonly generatedPath: string;
|
|
||||||
readonly localPath: string;
|
|
||||||
readonly legacyPath: string;
|
|
||||||
readonly generated: string;
|
|
||||||
readonly local: string;
|
|
||||||
readonly legacy?: string;
|
|
||||||
readonly quarantinePath?: string;
|
|
||||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AgentEnvBoundaryError extends Error {
|
|
||||||
readonly diagnostic: AgentEnvironmentDiagnostic;
|
|
||||||
|
|
||||||
constructor(code: string, key: string, value: string) {
|
|
||||||
const diagnostic: AgentEnvironmentDiagnostic = {
|
|
||||||
code,
|
|
||||||
key,
|
|
||||||
sha256: hashValue(value),
|
|
||||||
};
|
|
||||||
super(`Agent environment rejected: code=${code} key=${key} sha256=${diagnostic.sha256}`);
|
|
||||||
this.name = 'AgentEnvBoundaryError';
|
|
||||||
this.diagnostic = diagnostic;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GENERATED_AGENT_ENV_KEYS = [
|
|
||||||
'MOSAIC_AGENT_NAME',
|
|
||||||
'MOSAIC_AGENT_CLASS',
|
|
||||||
'MOSAIC_AGENT_RUNTIME',
|
|
||||||
'MOSAIC_AGENT_MODEL',
|
|
||||||
'MOSAIC_AGENT_REASONING',
|
|
||||||
'MOSAIC_AGENT_TOOL_POLICY',
|
|
||||||
'MOSAIC_AGENT_WORKDIR',
|
|
||||||
'MOSAIC_TMUX_SOCKET',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const LOCAL_AGENT_ENV_KEYS = [
|
|
||||||
'MOSAIC_RUNTIME_BIN',
|
|
||||||
'MOSAIC_HEARTBEAT_RUN_DIR',
|
|
||||||
'MOSAIC_HEARTBEAT_INTERVAL',
|
|
||||||
'MOSAIC_CLAUDE_JSON',
|
|
||||||
'CLAUDE_CONFIG_DIR',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const GENERATED_KEY_SET = new Set<string>(GENERATED_AGENT_ENV_KEYS);
|
|
||||||
const LOCAL_KEY_SET = new Set<string>(LOCAL_AGENT_ENV_KEYS);
|
|
||||||
const SENSITIVE_KEY = /(?:API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN)/i;
|
|
||||||
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
||||||
const POLICY_NAME = /^[a-z][a-z0-9-]*$/;
|
|
||||||
const TMUX_SOCKET = /^[A-Za-z0-9_.-]*$/;
|
|
||||||
const MODEL = /^[A-Za-z0-9._/:+-]*$/;
|
|
||||||
/** Runtime launchers supported by the generated environment contract. */
|
|
||||||
export const GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES = [
|
|
||||||
'claude',
|
|
||||||
'codex',
|
|
||||||
'opencode',
|
|
||||||
'pi',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const SUPPORTED_RUNTIMES = new Set<string>(GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES);
|
|
||||||
const REASONING = new Set(['', 'low', 'medium', 'high']);
|
|
||||||
|
|
||||||
/** Parses a strict data-only generated or local environment file without shell evaluation. */
|
|
||||||
export function parseAgentEnvironment(
|
|
||||||
source: string,
|
|
||||||
kind: AgentEnvironmentKind,
|
|
||||||
): Readonly<Record<string, string>> {
|
|
||||||
const values: Record<string, string> = {};
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
for (const line of source.split('\n')) {
|
|
||||||
if (line === '') continue;
|
|
||||||
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
||||||
if (!match) throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
|
|
||||||
|
|
||||||
const [, key, value] = match;
|
|
||||||
if (key === undefined || value === undefined) {
|
|
||||||
throw new AgentEnvBoundaryError('malformed-line', '(malformed)', line);
|
|
||||||
}
|
|
||||||
if (seen.has(key)) throw new AgentEnvBoundaryError('duplicate-key', key, value);
|
|
||||||
seen.add(key);
|
|
||||||
if (SENSITIVE_KEY.test(key)) throw new AgentEnvBoundaryError('sensitive-key', key, value);
|
|
||||||
if (containsShellSyntax(value)) throw new AgentEnvBoundaryError('unsafe-value', key, value);
|
|
||||||
|
|
||||||
if (kind === 'generated') {
|
|
||||||
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
|
||||||
} else {
|
|
||||||
if (GENERATED_KEY_SET.has(key))
|
|
||||||
throw new AgentEnvBoundaryError('generated-key-shadow', key, value);
|
|
||||||
if (!LOCAL_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
|
||||||
}
|
|
||||||
values[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === 'generated') assertGeneratedValues(values);
|
|
||||||
else assertLocalValues(values);
|
|
||||||
return Object.freeze(values);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
|
||||||
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
|
||||||
const normalized = normalizeGeneratedValues(values);
|
|
||||||
return `${GENERATED_AGENT_ENV_KEYS.map((key): string => `${key}=${normalized[key]}`).join('\n')}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validates all deterministic projection inputs and target state without mutation. */
|
|
||||||
export async function prepareAgentEnvironmentProjection(
|
|
||||||
options: AgentEnvironmentProjectionOptions,
|
|
||||||
): Promise<PreparedAgentEnvironmentProjection> {
|
|
||||||
if (!AGENT_NAME.test(options.agentName)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
|
||||||
}
|
|
||||||
|
|
||||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
|
||||||
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
|
||||||
const localPath = join(options.agentEnvDir, `${options.agentName}.env.local`);
|
|
||||||
const legacyPath = join(options.agentEnvDir, `${options.agentName}.env`);
|
|
||||||
const generated = renderGeneratedAgentEnvironment(options.generated);
|
|
||||||
await assertPrivateRegularFileIfPresent(generatedPath);
|
|
||||||
const currentLocal = await readOptionalPrivateFile(localPath);
|
|
||||||
const parsedLocal =
|
|
||||||
currentLocal === undefined ? {} : parseAgentEnvironment(currentLocal, 'local');
|
|
||||||
const legacy = await readOptionalPrivateFile(legacyPath);
|
|
||||||
const legacyDisposition =
|
|
||||||
legacy === undefined ? emptyLegacyDisposition() : classifyLegacyEnvironment(legacy);
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(legacyDisposition.localValues)) {
|
|
||||||
if (parsedLocal[key] !== undefined && parsedLocal[key] !== value) {
|
|
||||||
throw new AgentEnvBoundaryError('legacy-local-conflict', key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const local = renderLocalAgentEnvironment({ ...legacyDisposition.localValues, ...parsedLocal });
|
|
||||||
const quarantinePath = legacyDisposition.diagnostics.length
|
|
||||||
? join(options.agentEnvDir, `${options.agentName}.env.quarantine`)
|
|
||||||
: undefined;
|
|
||||||
if (quarantinePath !== undefined) {
|
|
||||||
const existingQuarantine = await readOptionalPrivateFile(quarantinePath);
|
|
||||||
if (existingQuarantine !== undefined) {
|
|
||||||
throw new AgentEnvBoundaryError('quarantine-exists', '(quarantine)', existingQuarantine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mosaicHome: options.mosaicHome,
|
|
||||||
agentEnvDir: options.agentEnvDir,
|
|
||||||
generatedPath,
|
|
||||||
localPath,
|
|
||||||
legacyPath,
|
|
||||||
generated,
|
|
||||||
local,
|
|
||||||
...(legacy === undefined ? {} : { legacy }),
|
|
||||||
...(quarantinePath === undefined ? {} : { quarantinePath }),
|
|
||||||
diagnostics: legacyDisposition.diagnostics,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates only the exact generated projection eligible for a roster delete.
|
|
||||||
* Local overrides, legacy input, and quarantine records are operator-retained and
|
|
||||||
* intentionally excluded from delete validation and cleanup.
|
|
||||||
*/
|
|
||||||
export async function prepareAgentGeneratedProjectionDeletion(
|
|
||||||
options: AgentGeneratedProjectionDeletionOptions,
|
|
||||||
): Promise<string> {
|
|
||||||
if (!AGENT_NAME.test(options.agentName)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
|
||||||
}
|
|
||||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
|
||||||
const generatedPath = join(options.agentEnvDir, `${options.agentName}.env.generated`);
|
|
||||||
await assertPrivateRegularFileIfPresent(generatedPath);
|
|
||||||
return generatedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Applies a previously prepared deterministic projection. */
|
|
||||||
export async function applyPreparedAgentEnvironmentProjection(
|
|
||||||
prepared: PreparedAgentEnvironmentProjection,
|
|
||||||
): Promise<AgentEnvironmentProjectionResult> {
|
|
||||||
await ensurePrivateProjectionDirectory(prepared.mosaicHome, prepared.agentEnvDir);
|
|
||||||
await writePrivateAtomically(prepared.generatedPath, prepared.generated);
|
|
||||||
if (prepared.local !== '') await writePrivateAtomically(prepared.localPath, prepared.local);
|
|
||||||
if (prepared.quarantinePath !== undefined && prepared.legacy !== undefined) {
|
|
||||||
await writePrivateAtomically(prepared.quarantinePath, prepared.legacy);
|
|
||||||
}
|
|
||||||
if (prepared.legacy !== undefined) await unlink(prepared.legacyPath);
|
|
||||||
|
|
||||||
return {
|
|
||||||
generatedPath: prepared.generatedPath,
|
|
||||||
localPath: prepared.localPath,
|
|
||||||
...(prepared.quarantinePath === undefined ? {} : { quarantinePath: prepared.quarantinePath }),
|
|
||||||
diagnostics: prepared.diagnostics,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Writes deterministic projection files and explicitly removes legacy `.env` authority. */
|
|
||||||
export async function writeAgentEnvironmentProjection(
|
|
||||||
options: AgentEnvironmentProjectionOptions,
|
|
||||||
): Promise<AgentEnvironmentProjectionResult> {
|
|
||||||
return applyPreparedAgentEnvironmentProjection(await prepareAgentEnvironmentProjection(options));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates the canonical managed roster path with private fresh-chain permissions. */
|
|
||||||
export async function writeManagedFleetRoster(
|
|
||||||
mosaicHome: string,
|
|
||||||
rosterPath: string,
|
|
||||||
content: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const fleetDir = join(mosaicHome, 'fleet');
|
|
||||||
const expectedRosterPath = join(fleetDir, 'roster.yaml');
|
|
||||||
if (resolve(rosterPath) !== resolve(expectedRosterPath)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-file', '(roster)', rosterPath);
|
|
||||||
}
|
|
||||||
await ensureManagedDirectory(mosaicHome, false);
|
|
||||||
await ensureManagedDirectory(fleetDir, false);
|
|
||||||
await writePrivateAtomically(rosterPath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ensures the private install-derived identity used to own a named tmux server. */
|
|
||||||
export async function ensureFleetHolderIdentity(mosaicHome: string): Promise<string> {
|
|
||||||
const fleetDir = join(mosaicHome, 'fleet');
|
|
||||||
const runDir = join(fleetDir, 'run');
|
|
||||||
const identityPath = join(runDir, 'holder-owner');
|
|
||||||
await ensureManagedDirectory(mosaicHome, false);
|
|
||||||
await ensureManagedDirectory(fleetDir, false);
|
|
||||||
await ensureManagedDirectory(runDir, true);
|
|
||||||
const existing = await readOptionalPrivateFile(identityPath);
|
|
||||||
if (existing !== undefined) {
|
|
||||||
const identity = existing.trim();
|
|
||||||
if (!/^[a-f0-9-]{36}$/.test(identity)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-owner-identity', '(holder-owner)', existing);
|
|
||||||
}
|
|
||||||
return identity;
|
|
||||||
}
|
|
||||||
const identity = randomUUID();
|
|
||||||
await writePrivateAtomically(identityPath, `${identity}\n`);
|
|
||||||
return identity;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeGeneratedValues(
|
|
||||||
values: Readonly<Record<string, string>>,
|
|
||||||
): Readonly<Record<string, string>> {
|
|
||||||
const normalized: Record<string, string> = {};
|
|
||||||
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
|
||||||
const value = values[key];
|
|
||||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
|
||||||
normalized[key] = value;
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
if (!GENERATED_KEY_SET.has(key)) throw new AgentEnvBoundaryError('unknown-key', key, value);
|
|
||||||
}
|
|
||||||
assertGeneratedValues(normalized);
|
|
||||||
return Object.freeze(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLocalAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
|
||||||
if (Object.keys(values).length === 0) return '';
|
|
||||||
const parsed = parseAgentEnvironment(
|
|
||||||
Object.entries(values)
|
|
||||||
.sort(([left], [right]): number => left.localeCompare(right))
|
|
||||||
.map(([key, value]): string => `${key}=${value}`)
|
|
||||||
.join('\n'),
|
|
||||||
'local',
|
|
||||||
);
|
|
||||||
return `${Object.entries(parsed)
|
|
||||||
.sort(([left], [right]): number => left.localeCompare(right))
|
|
||||||
.map(([key, value]): string => `${key}=${value}`)
|
|
||||||
.join('\n')}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertGeneratedValues(values: Readonly<Record<string, string>>): void {
|
|
||||||
for (const key of GENERATED_AGENT_ENV_KEYS) {
|
|
||||||
const value = values[key];
|
|
||||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
|
||||||
}
|
|
||||||
const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME');
|
|
||||||
const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS');
|
|
||||||
const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME');
|
|
||||||
const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL');
|
|
||||||
const reasoning = requiredGeneratedValue(values, 'MOSAIC_AGENT_REASONING');
|
|
||||||
const toolPolicy = requiredGeneratedValue(values, 'MOSAIC_AGENT_TOOL_POLICY');
|
|
||||||
const workingDirectory = requiredGeneratedValue(values, 'MOSAIC_AGENT_WORKDIR');
|
|
||||||
const socket = requiredGeneratedValue(values, 'MOSAIC_TMUX_SOCKET');
|
|
||||||
|
|
||||||
if (!AGENT_NAME.test(name))
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name);
|
|
||||||
if (!POLICY_NAME.test(className)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className);
|
|
||||||
}
|
|
||||||
if (!SUPPORTED_RUNTIMES.has(runtime)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsupported-runtime', 'MOSAIC_AGENT_RUNTIME', runtime);
|
|
||||||
}
|
|
||||||
if (!MODEL.test(model))
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-model', 'MOSAIC_AGENT_MODEL', model);
|
|
||||||
if (!REASONING.has(reasoning)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsupported-reasoning', 'MOSAIC_AGENT_REASONING', reasoning);
|
|
||||||
}
|
|
||||||
if (toolPolicy !== '' && !POLICY_NAME.test(toolPolicy)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-tool-policy', 'MOSAIC_AGENT_TOOL_POLICY', toolPolicy);
|
|
||||||
}
|
|
||||||
if (!isSafeAbsolutePath(workingDirectory)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-path', 'MOSAIC_AGENT_WORKDIR', workingDirectory);
|
|
||||||
}
|
|
||||||
if (!TMUX_SOCKET.test(socket)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-socket', 'MOSAIC_TMUX_SOCKET', socket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredGeneratedValue(values: Readonly<Record<string, string>>, key: string): string {
|
|
||||||
const value = values[key];
|
|
||||||
if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, '');
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertLocalValues(values: Readonly<Record<string, string>>): void {
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
if (key === 'MOSAIC_HEARTBEAT_INTERVAL') {
|
|
||||||
if (!/^[1-9][0-9]*$/.test(value)) {
|
|
||||||
throw new AgentEnvBoundaryError('invalid-interval', key, value);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isSafeAbsolutePath(value)) throw new AgentEnvBoundaryError('unsafe-path', key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSafeAbsolutePath(value: string): boolean {
|
|
||||||
return (
|
|
||||||
value.startsWith('/') && !value.split('/').includes('..') && !/[\s"'`$\\;&|<>(){}]/.test(value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function containsShellSyntax(value: string): boolean {
|
|
||||||
return /["'`$\\;&|<>(){}]/.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LegacyDisposition {
|
|
||||||
readonly localValues: Readonly<Record<string, string>>;
|
|
||||||
readonly diagnostics: readonly AgentEnvironmentDiagnostic[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyLegacyDisposition(): LegacyDisposition {
|
|
||||||
return { localValues: {}, diagnostics: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function classifyLegacyEnvironment(source: string): LegacyDisposition {
|
|
||||||
const localValues: Record<string, string> = {};
|
|
||||||
const diagnostics: AgentEnvironmentDiagnostic[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
for (const line of source.split('\n')) {
|
|
||||||
if (line === '') continue;
|
|
||||||
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
||||||
if (!match || match[1] === undefined || match[2] === undefined) {
|
|
||||||
diagnostics.push(diagnostic('malformed-line', '(malformed)', line));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const [, key, value] = match;
|
|
||||||
if (seen.has(key)) {
|
|
||||||
diagnostics.push(diagnostic('duplicate-key', key, value));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.add(key);
|
|
||||||
if (GENERATED_KEY_SET.has(key)) continue;
|
|
||||||
try {
|
|
||||||
parseAgentEnvironment(`${key}=${value}\n`, 'local');
|
|
||||||
localValues[key] = value;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof AgentEnvBoundaryError) diagnostics.push(error.diagnostic);
|
|
||||||
else throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { localValues: Object.freeze(localValues), diagnostics: Object.freeze(diagnostics) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function diagnostic(code: string, key: string, value: string): AgentEnvironmentDiagnostic {
|
|
||||||
return { code, key, sha256: hashValue(value) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function hashValue(value: string): string {
|
|
||||||
return createHash('sha256').update(value).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readOptionalPrivateFile(path: string): Promise<string | undefined> {
|
|
||||||
try {
|
|
||||||
await assertPrivateRegularFile(path);
|
|
||||||
return await readFile(path, 'utf8');
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (isMissingFile(error)) return undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMissingFile(error: unknown): boolean {
|
|
||||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validatePrivateProjectionDirectory(
|
|
||||||
mosaicHome: string,
|
|
||||||
agentEnvDir: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const fleetDir = join(mosaicHome, 'fleet');
|
|
||||||
const expectedAgentEnvDir = join(fleetDir, 'agents');
|
|
||||||
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
|
|
||||||
}
|
|
||||||
await assertManagedDirectoryIfPresent(mosaicHome, false);
|
|
||||||
await assertManagedDirectoryIfPresent(fleetDir, false);
|
|
||||||
await assertManagedDirectoryIfPresent(agentEnvDir, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensurePrivateProjectionDirectory(
|
|
||||||
mosaicHome: string,
|
|
||||||
agentEnvDir: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
|
|
||||||
const fleetDir = join(mosaicHome, 'fleet');
|
|
||||||
await ensureManagedDirectory(mosaicHome, false);
|
|
||||||
await ensureManagedDirectory(fleetDir, false);
|
|
||||||
await ensureManagedDirectory(agentEnvDir, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
|
|
||||||
let created = false;
|
|
||||||
try {
|
|
||||||
await lstat(path);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!isMissingFile(error)) throw error;
|
|
||||||
try {
|
|
||||||
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
||||||
created = true;
|
|
||||||
} catch (mkdirError: unknown) {
|
|
||||||
if (!isAlreadyExists(mkdirError)) throw mkdirError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await assertManagedDirectory(path, privateDirectory);
|
|
||||||
if (created) {
|
|
||||||
await chmod(path, 0o700);
|
|
||||||
await assertManagedDirectory(path, privateDirectory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAlreadyExists(error: unknown): boolean {
|
|
||||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertManagedDirectoryIfPresent(
|
|
||||||
path: string,
|
|
||||||
privateDirectory: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await assertManagedDirectory(path, privateDirectory);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (isMissingFile(error)) return;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertManagedDirectory(path: string, privateDirectory: boolean): Promise<void> {
|
|
||||||
const metadata = await lstat(path);
|
|
||||||
if (!metadata.isDirectory()) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', path);
|
|
||||||
}
|
|
||||||
if ((metadata.mode & 0o022) !== 0 || (privateDirectory && (metadata.mode & 0o077) !== 0)) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-permissions', '(directory)', path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertPrivateRegularFileIfPresent(path: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await assertPrivateRegularFile(path);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (isMissingFile(error)) return;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertPrivateRegularFile(path: string): Promise<void> {
|
|
||||||
const metadata = await lstat(path);
|
|
||||||
if (!metadata.isFile()) throw new AgentEnvBoundaryError('unsafe-file', '(file)', path);
|
|
||||||
if ((metadata.mode & 0o077) !== 0) {
|
|
||||||
throw new AgentEnvBoundaryError('unsafe-permissions', '(file)', path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writePrivateAtomically(path: string, content: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await assertPrivateRegularFile(path);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!isMissingFile(error)) throw error;
|
|
||||||
}
|
|
||||||
const temporaryPath = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
||||||
await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
||||||
await rename(temporaryPath, path);
|
|
||||||
}
|
|
||||||
@@ -77,25 +77,12 @@ describe('readPersonaContractBlock — launch-time persona injection (A3b)', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('injects an override-only (user-added) persona with no baseline at all', () => {
|
it('injects an override-only (user-added) persona with no baseline at all', () => {
|
||||||
seedOverride(home, 'mascot', '# Mascot\n\n(`class: mascot`)\n\nCUSTOM-ROLE.\n');
|
seedOverride(home, 'reviewer', '# Reviewer\n\n(`class: reviewer`)\n\nCUSTOM-ROLE.\n');
|
||||||
const block = readPersonaContractBlock(home, 'mascot');
|
const block = readPersonaContractBlock(home, 'reviewer');
|
||||||
expect(block).toContain('# Persona Contract (mascot)');
|
expect(block).toContain('# Persona Contract (reviewer)');
|
||||||
expect(block).toContain('CUSTOM-ROLE');
|
expect(block).toContain('CUSTOM-ROLE');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('canonicalizes an approved alias before launch-time override lookup', () => {
|
|
||||||
seedBaseline(home, 'code', '# Code\n\n(`class: code`)\n\nCANONICAL-CODE.\n');
|
|
||||||
seedOverride(
|
|
||||||
home,
|
|
||||||
'implementer',
|
|
||||||
'# Legacy implementer\n\n(`class: implementer`)\n\nLEGACY-OVERRIDE.\n',
|
|
||||||
);
|
|
||||||
const block = readPersonaContractBlock(home, 'implementer');
|
|
||||||
expect(block).toContain('# Persona Contract (code)');
|
|
||||||
expect(block).toContain('CANONICAL-CODE');
|
|
||||||
expect(block).not.toContain('LEGACY-OVERRIDE');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no-ops (empty string) when the class is undefined', () => {
|
it('no-ops (empty string) when the class is undefined', () => {
|
||||||
seedBaseline(home, 'coder', BASELINE_CODER);
|
seedBaseline(home, 'coder', BASELINE_CODER);
|
||||||
expect(readPersonaContractBlock(home, undefined)).toBe('');
|
expect(readPersonaContractBlock(home, undefined)).toBe('');
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
ROSTER_V2_JSON_SCHEMA,
|
ROSTER_V2_JSON_SCHEMA,
|
||||||
RosterV2ValidationError,
|
RosterV2ValidationError,
|
||||||
parseRosterV2,
|
parseRosterV2,
|
||||||
renderRosterV2Yaml,
|
renderRosterV2Yaml,
|
||||||
validateRosterV2Semantics,
|
|
||||||
} from './roster-v2.js';
|
} from './roster-v2.js';
|
||||||
|
|
||||||
const validRoster = `
|
const validRoster = `
|
||||||
@@ -43,127 +40,6 @@ agents:
|
|||||||
yolo: true
|
yolo: true
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let semanticTmp: string | undefined;
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
|
|
||||||
semanticTmp = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
|
|
||||||
semanticTmp = await mkdtemp(join(tmpdir(), 'roster-v2-semantics-'));
|
|
||||||
const rolesDir = join(semanticTmp, 'roles');
|
|
||||||
const overrideDir = join(semanticTmp, 'roles.local');
|
|
||||||
await mkdir(rolesDir, { recursive: true });
|
|
||||||
await mkdir(overrideDir, { recursive: true });
|
|
||||||
for (const klass of [
|
|
||||||
'code',
|
|
||||||
'review',
|
|
||||||
'interaction',
|
|
||||||
'orchestrator',
|
|
||||||
'merge-gate',
|
|
||||||
'validator',
|
|
||||||
'team-leader',
|
|
||||||
]) {
|
|
||||||
await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`, 'utf8');
|
|
||||||
}
|
|
||||||
return { rolesDir, overrideDir };
|
|
||||||
}
|
|
||||||
|
|
||||||
function rosterWithClass(klass: string, toolPolicy = klass): string {
|
|
||||||
return validRoster
|
|
||||||
.replace('class: code', `class: ${klass}`)
|
|
||||||
.replace('tool_policy: code', `tool_policy: ${toolPolicy}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('roster v2 semantic validation', (): void => {
|
|
||||||
it.each([
|
|
||||||
['implementer', 'code'],
|
|
||||||
['reviewer', 'review'],
|
|
||||||
['operator-interaction', 'interaction'],
|
|
||||||
])(
|
|
||||||
'canonicalizes requested alias %s while retaining requested and canonical class',
|
|
||||||
async (requested: string, canonical: string) => {
|
|
||||||
const dirs = await semanticDirs();
|
|
||||||
const roster = parseRosterV2(rosterWithClass(requested, requested), 'yaml');
|
|
||||||
const validated = await validateRosterV2Semantics(roster, dirs);
|
|
||||||
expect(validated.agents[0]).toMatchObject({
|
|
||||||
requestedClass: requested,
|
|
||||||
canonicalClass: canonical,
|
|
||||||
canonicalToolPolicy: canonical,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['worker', 'analyst', 'canary'])(
|
|
||||||
'rejects %s when no genuine custom role exists',
|
|
||||||
async (klass: string) => {
|
|
||||||
const dirs = await semanticDirs();
|
|
||||||
await expect(
|
|
||||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass), 'yaml'), dirs),
|
|
||||||
).rejects.toThrow(/unresolved|readable persona/i);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('accepts a genuine custom roles.local class without protected authority', async () => {
|
|
||||||
const dirs = await semanticDirs();
|
|
||||||
await writeFile(join(dirs.overrideDir, 'worker.md'), '# worker\n\n(`class: worker`)\n', 'utf8');
|
|
||||||
const validated = await validateRosterV2Semantics(
|
|
||||||
parseRosterV2(rosterWithClass('worker'), 'yaml'),
|
|
||||||
dirs,
|
|
||||||
);
|
|
||||||
expect(validated.agents[0]?.authority).toMatchObject({
|
|
||||||
mayMerge: false,
|
|
||||||
mayOrchestrate: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a LIBRARY-only class with no readable resolved persona', async () => {
|
|
||||||
const dirs = await semanticDirs();
|
|
||||||
await writeFile(
|
|
||||||
join(dirs.rolesDir, 'LIBRARY.md'),
|
|
||||||
'| Persona | Purpose |\n| --- | --- |\n| phantom | Missing |\n',
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass('phantom'), 'yaml'), dirs),
|
|
||||||
).rejects.toThrow(/readable persona/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unreadable resolved persona', async () => {
|
|
||||||
const dirs = await semanticDirs();
|
|
||||||
await mkdir(join(dirs.overrideDir, 'worker.md'));
|
|
||||||
await expect(
|
|
||||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs),
|
|
||||||
).rejects.toThrow(/readable persona/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['merge-gate', 'code'],
|
|
||||||
['validator', 'merge-gate'],
|
|
||||||
['orchestrator', 'interaction'],
|
|
||||||
['team-leader', 'orchestrator'],
|
|
||||||
['interaction', 'orchestrator'],
|
|
||||||
['code', 'merge-gate'],
|
|
||||||
['worker', 'validator'],
|
|
||||||
])(
|
|
||||||
'denies protected class/tool-policy mismatch %s with %s',
|
|
||||||
async (klass: string, toolPolicy: string) => {
|
|
||||||
const dirs = await semanticDirs();
|
|
||||||
if (klass === 'worker') {
|
|
||||||
await writeFile(
|
|
||||||
join(dirs.overrideDir, 'worker.md'),
|
|
||||||
'# worker\n\n(`class: worker`)\n',
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await expect(
|
|
||||||
validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass, toolPolicy), 'yaml'), dirs),
|
|
||||||
).rejects.toThrow(/tool policy.*must match|mismatch/i);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('roster v2 structural compiler', (): void => {
|
describe('roster v2 structural compiler', (): void => {
|
||||||
it('parses YAML into a normalized typed model and renders canonical YAML', (): void => {
|
it('parses YAML into a normalized typed model and renders canonical YAML', (): void => {
|
||||||
const roster = parseRosterV2(validRoster, 'yaml');
|
const roster = parseRosterV2(validRoster, 'yaml');
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import {
|
|
||||||
authorityForCanonicalClass,
|
|
||||||
canonicalizeRoleClass,
|
|
||||||
defaultOverrideDir,
|
|
||||||
defaultRolesDir,
|
|
||||||
extractClassesFromDir,
|
|
||||||
resolvePersonaFrom,
|
|
||||||
type PersonaDirs,
|
|
||||||
type PersonaResolution,
|
|
||||||
type RoleAuthority,
|
|
||||||
} from '../commands/fleet-personas.js';
|
|
||||||
|
|
||||||
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
|
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
|
||||||
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
|
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
|
||||||
@@ -69,80 +58,6 @@ export interface FleetRosterV2 {
|
|||||||
readonly agents: readonly FleetRosterV2Agent[];
|
readonly agents: readonly FleetRosterV2Agent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SemanticallyValidatedRosterV2Agent extends FleetRosterV2Agent {
|
|
||||||
readonly requestedClass: string;
|
|
||||||
readonly canonicalClass: string;
|
|
||||||
readonly canonicalToolPolicy: string;
|
|
||||||
readonly persona: PersonaResolution;
|
|
||||||
readonly authority: RoleAuthority;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SemanticallyValidatedRosterV2 extends Omit<FleetRosterV2, 'agents'> {
|
|
||||||
readonly agents: readonly SemanticallyValidatedRosterV2Agent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROTECTED_TOOL_POLICY_CLASSES = new Set([
|
|
||||||
'merge-gate',
|
|
||||||
'validator',
|
|
||||||
'orchestrator',
|
|
||||||
'team-leader',
|
|
||||||
'interaction',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate filesystem-backed roster semantics after synchronous structural parsing.
|
|
||||||
* Directory scans are batched once and every class must resolve to readable content.
|
|
||||||
*/
|
|
||||||
export async function validateRosterV2Semantics(
|
|
||||||
roster: FleetRosterV2,
|
|
||||||
opts: PersonaDirs = {},
|
|
||||||
): Promise<SemanticallyValidatedRosterV2> {
|
|
||||||
const rolesDir = opts.rolesDir ?? defaultRolesDir(opts.mosaicHome);
|
|
||||||
const overrideDir = opts.overrideDir ?? defaultOverrideDir(opts.mosaicHome);
|
|
||||||
const [base, over] = await Promise.all([
|
|
||||||
extractClassesFromDir(rolesDir),
|
|
||||||
extractClassesFromDir(overrideDir),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const agents: SemanticallyValidatedRosterV2Agent[] = [];
|
|
||||||
for (const agent of roster.agents) {
|
|
||||||
const requestedClass = agent.className;
|
|
||||||
const { canonicalClass } = canonicalizeRoleClass(requestedClass);
|
|
||||||
const canonicalToolPolicy = canonicalizeRoleClass(agent.toolPolicy).canonicalClass;
|
|
||||||
const persona = await resolvePersonaFrom(requestedClass, {
|
|
||||||
rolesDir,
|
|
||||||
overrideDir,
|
|
||||||
base,
|
|
||||||
over,
|
|
||||||
});
|
|
||||||
if (!persona || persona.content.trim() === '') {
|
|
||||||
throw new RosterV2ValidationError(
|
|
||||||
`Roster v2 agent "${agent.name}" class "${requestedClass}" does not resolve to a readable persona.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(PROTECTED_TOOL_POLICY_CLASSES.has(canonicalClass) ||
|
|
||||||
PROTECTED_TOOL_POLICY_CLASSES.has(canonicalToolPolicy)) &&
|
|
||||||
canonicalToolPolicy !== canonicalClass
|
|
||||||
) {
|
|
||||||
throw new RosterV2ValidationError(
|
|
||||||
`Roster v2 agent "${agent.name}" protected class "${canonicalClass}" tool policy must match its canonical class; received "${agent.toolPolicy}".`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
agents.push(
|
|
||||||
Object.freeze({
|
|
||||||
...agent,
|
|
||||||
requestedClass,
|
|
||||||
canonicalClass,
|
|
||||||
canonicalToolPolicy,
|
|
||||||
persona,
|
|
||||||
authority: authorityForCanonicalClass(canonicalClass),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Object.freeze({ ...roster, agents: Object.freeze(agents) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RosterV2ValidationError extends Error {
|
export class RosterV2ValidationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|||||||
Reference in New Issue
Block a user