From 191efaefeb5c0c6bb218c1292d12ce8e73ace12b Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Wed, 15 Jul 2026 08:40:32 +0000 Subject: [PATCH] feat(fleet): enforce generated environment boundary (#772) --- docs/SITEMAP.md | 7 + docs/fleet/FLEET-LAUNCH.md | 149 ++-- .../fleet/reference/generated-env-boundary.md | 96 +++ .../fcm-m2-001-generated-env-boundary.md | 110 +++ packages/mosaic/framework/fleet/README.md | 14 +- .../mosaic/framework/systemd/user/README.md | 65 +- .../systemd/user/mosaic-agent@.service | 15 +- .../user/mosaic-interaction-agent@.service | 10 +- .../systemd/user/mosaic-tmux-holder.service | 9 +- .../systemd/user/test-fleet-units.sh | 130 +++- .../tools/fleet/start-agent-session.sh | 456 +++++++----- .../tools/fleet/start-interaction-service.sh | 10 +- .../tools/fleet/start-tmux-holder.sh | 64 ++ .../tools/fleet/test-start-agent-session.sh | 703 +++++++++--------- packages/mosaic/src/commands/fleet.spec.ts | 294 ++++++-- packages/mosaic/src/commands/fleet.ts | 147 ++-- .../src/fleet/generated-env-boundary.spec.ts | 279 +++++++ .../src/fleet/generated-env-boundary.ts | 509 +++++++++++++ 18 files changed, 2282 insertions(+), 785 deletions(-) create mode 100644 docs/fleet/reference/generated-env-boundary.md create mode 100644 docs/scratchpads/fcm-m2-001-generated-env-boundary.md create mode 100755 packages/mosaic/framework/tools/fleet/start-tmux-holder.sh create mode 100644 packages/mosaic/src/fleet/generated-env-boundary.spec.ts create mode 100644 packages/mosaic/src/fleet/generated-env-boundary.ts diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 487df03..1ac19d5 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -1,5 +1,12 @@ # 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 - [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts. diff --git a/docs/fleet/FLEET-LAUNCH.md b/docs/fleet/FLEET-LAUNCH.md index 515aabe..8b685c7 100644 --- a/docs/fleet/FLEET-LAUNCH.md +++ b/docs/fleet/FLEET-LAUNCH.md @@ -1,114 +1,77 @@ # Fleet Launch Runbook -How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to -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. +The local fleet roster is the sole writable desired-state authority for membership and launch policy. +Generated environment files are rebuildable projections, not an operator-editable command surface. -## The launch chain +## Launch chain -| Layer | File | Responsibility | -| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| systemd unit | `mosaic-agent@.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`. | -| session launcher | `tools/fleet/start-agent-session.sh ` | Builds the launch command, opens the tmux pane, wires the heartbeat. | -| launch command | `mosaic yolo ` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. | -| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. | +| Layer | Responsibility | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. | +| Projection writer | Renders deterministic `fleet/agents/.env.generated` from the roster. | +| Optional local data | Reads a strict, data-only `fleet/agents/.env.local`; it cannot shadow generated keys. | +| systemd | Starts the launcher with `env -i` and fixed bootstrap data. It does not preload either environment file. | +| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. | +| runtime launch | Derives the fixed `mosaic yolo ` argument array from validated roster data, then seeds the runtime contract. | -Per-agent overrides live in `fleet/agents/.env`, generated from `roster.yaml` by -`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher. +The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied +command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing, +secret-like key names, duplicate keys, comments, quoted/export syntax, and unsafe values are rejected. -## Worker launch path (default) +## Generated and local files -1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`. -2. `generateAgentEnv` emits `fleet/agents/.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. +`.env.generated` is complete, deterministic, and written only by Mosaic. Its ordered keys are: -That is the whole worker path: roster → `.env` → `mosaic yolo ` → seeded pane. - -## Orchestrator fold (PATH A — ships today) - -The orchestrator is **just another roster agent** launched through the canonical path — not a -snowflake script. - -| Piece | Value | -| ------------------ | ----------------------------------- | -| 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@' +```dotenv +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= ``` -When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]` -guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped -entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude` -(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. +The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. `mosaic fleet add` +rejects another runtime before it writes the roster or modifies generated, local, or quarantine state. +The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket; +it has no generated-launch adapter and cannot be added through this path. -## Launch gotchas +`.env.local` is optional and may contain only non-secret machine data: -1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do - **not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both. - Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo - `mosaic claude --permission-mode bypassPermissions …`. Never mix the two. -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. +- `MOSAIC_RUNTIME_BIN` +- `MOSAIC_HEARTBEAT_RUN_DIR` +- `MOSAIC_HEARTBEAT_INTERVAL` +- `MOSAIC_CLAUDE_JSON` +- `CLAUDE_CONFIG_DIR` -## Why per-agent `.env` survives upgrades (#632) +Paths must be safe absolute paths and the heartbeat interval must be a positive integer. Projection, +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. -`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. +## Legacy input and diagnostics -## Inspecting the comms wiring +A legacy `.env` is input only during projection generation. Roster-owned keys are regenerated; +valid allowed local data can move to `.env.local`; invalid legacy input is privately retained at +`.env.quarantine`. Neither legacy nor quarantine files are launch authority. -- `mosaic fleet comms-block ` prints the Fleet-Comms cheat-sheet a given role receives at - launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the - FLIP / `--verify` conventions. `--host ` 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 `: 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. +Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command +text, credentials, or other values. -## North Star / future direction +## Launch and stop behavior -**Vision:** a webUI lets the user edit each agent's launch config — switch **harness** -(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels** -override — with no terminal. +The launcher obtains the agent's socket only from the validated generated projection. It creates or +checks the exact `=` tmux target; it never uses an ambient socket or fuzzy session match. +The same strict parser runs before exact-stop behavior. A fresh native Pi heartbeat remains authoritative; +the shell sidecar only provides fallback state when the native marker is stale or absent. -**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the -existing roster-driven launcher. Field-by-field status today: +`mosaic fleet comms-block ` can inspect the role's resolved Fleet-Comms block. It is a read-only +inspection tool and fails loudly for an unknown role or missing roster. -| 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. | +## Current M2 boundary -**The arc:** - -- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632. -- **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. +FCM-M2-001 supplies generated/local parsing, validation, projection, quarantine, and launch-boundary +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 +path; M4 separately provides migration preview, canary, and rollback gates. diff --git a/docs/fleet/reference/generated-env-boundary.md b/docs/fleet/reference/generated-env-boundary.md new file mode 100644 index 0000000..19d85c8 --- /dev/null +++ b/docs/fleet/reference/generated-env-boundary.md @@ -0,0 +1,96 @@ +# 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 `` under `/fleet/agents/`: + +| Path | Owner | Purpose | +| ----------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `.env.generated` | Mosaic projection writer | Complete deterministic launch data rendered from the authoritative roster. | +| `.env.local` | Operator | Optional, constrained local machine data. It cannot shadow generated keys. | +| `.env` | Legacy input only | Read once during projection generation, then regenerated/relocated or privately quarantined. It is never a launch authority. | +| `.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= +MOSAIC_AGENT_CLASS= +MOSAIC_AGENT_RUNTIME= +MOSAIC_AGENT_MODEL= +MOSAIC_AGENT_REASONING= +MOSAIC_AGENT_TOOL_POLICY= +MOSAIC_AGENT_WORKDIR= +MOSAIC_TMUX_SOCKET= +``` + +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 `.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 `/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. diff --git a/docs/scratchpads/fcm-m2-001-generated-env-boundary.md b/docs/scratchpads/fcm-m2-001-generated-env-boundary.md new file mode 100644 index 0000000..f71c46d --- /dev/null +++ b/docs/scratchpads/fcm-m2-001-generated-env-boundary.md @@ -0,0 +1,110 @@ +# 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 `.env.generated` projection and strict, data-only `.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/.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 `.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 `=` 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. diff --git a/packages/mosaic/framework/fleet/README.md b/packages/mosaic/framework/fleet/README.md index 36cfb1e..df42327 100644 --- a/packages/mosaic/framework/fleet/README.md +++ b/packages/mosaic/framework/fleet/README.md @@ -9,7 +9,8 @@ package, normally at: ``` The default tmux socket is `mosaic-fleet` so fleet commands do not touch the -default tmux server. +default tmux server. The roster is the desired-state authority; generated environment files are +rebuildable projections, never a second source of configuration. ## Examples @@ -31,6 +32,17 @@ The installed `tools/fleet/print-interaction-effective-policy.sh` prints only the resolved name, runtime, model, reasoning, and tool policy. It never reads or prints credential variables. +## Generated agent environment boundary + +`mosaic fleet install` writes a private deterministic projection at +`~/.config/mosaic/fleet/agents/.env.generated`. It may relocate only approved local machine +data to `.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: ```bash diff --git a/packages/mosaic/framework/systemd/user/README.md b/packages/mosaic/framework/systemd/user/README.md index 1811ead..e0df756 100644 --- a/packages/mosaic/framework/systemd/user/README.md +++ b/packages/mosaic/framework/systemd/user/README.md @@ -24,45 +24,56 @@ The agent template calls: which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`. -## Local customization +## Generated environment and local data -Per-agent overrides live outside the package in: +The roster-derived projection is written outside the package at: ```text -~/.config/mosaic/fleet/agents/.env +~/.config/mosaic/fleet/agents/.env.generated ``` -Example: +Systemd does not read either environment file. It starts the launcher with a fixed cleared bootstrap +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: -```dotenv -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 +```text +~/.config/mosaic/fleet/agents/.env.local ``` +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 `.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 -```bash -mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents -cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/ -cp packages/mosaic/framework/tools/fleet/*.sh ~/.config/mosaic/tools/fleet/ -chmod +x ~/.config/mosaic/tools/fleet/*.sh -systemctl --user daemon-reload -systemctl --user start mosaic-tmux-holder.service -systemctl --user start mosaic-agent@canary.service -tmux -L mosaic-fleet ls +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. -# For an operator-interaction service, the roster/env identity selects the -# generic unit instance; no service source is renamed for an instance. +```bash +# Create a site-owned canary roster. Inspect an existing roster before using --force. +mosaic fleet init --profile minimal --write +mosaic fleet install +systemctl --user daemon-reload +mosaic fleet start canary-pi +tmux -L mosaic-fleet ls +``` + +For an operator-interaction service, first put `` in the roster with the pinned Pi +runtime, model, reasoning, and `operator-interaction` tool policy. Re-run `mosaic fleet install` after +that roster change so it writes `.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@.service -MOSAIC_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 +~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh ``` Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant diff --git a/packages/mosaic/framework/systemd/user/mosaic-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-agent@.service index 0ebdec9..f4d4a98 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-agent@.service +++ b/packages/mosaic/framework/systemd/user/mosaic-agent@.service @@ -7,16 +7,13 @@ PartOf=mosaic-tmux-holder.service [Service] Type=oneshot +# Remove loader and noninteractive-shell controls before ExecStart loads env. +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV RemainAfterExit=yes -# No default MOSAIC_TMUX_SOCKET: an absent roster socket means the literal -# default tmux socket (no -L). The per-agent .env sets it when the roster names -# one; otherwise it stays unset and start-agent-session.sh uses the default socket. -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' +# Never preload the projection. The launcher starts from a fixed minimal +# environment and strictly validates generated/local data before tmux effects. +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 +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 [Install] WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service b/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service index 346dbd8..9bc5831 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service +++ b/packages/mosaic/framework/systemd/user/mosaic-interaction-agent@.service @@ -7,11 +7,13 @@ PartOf=mosaic-tmux-holder.service [Service] Type=oneshot +# Remove loader and noninteractive-shell controls before ExecStart loads env. +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV RemainAfterExit=yes -Environment=MOSAIC_AGENT_NAME=%i -EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env -ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-interaction-service.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' +# The interaction wrapper delegates to the shared strict parser before pinned +# profile checks; no projection data reaches Bash through systemd. +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 +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 [Install] WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service b/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service index a4ae3ae..7795efd 100644 --- a/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service +++ b/packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service @@ -6,10 +6,11 @@ After=default.target [Service] Type=oneshot RemainAfterExit=yes -Environment=MOSAIC_TMUX_SOCKET=mosaic-fleet -Environment=MOSAIC_TMUX_HOLDER=_holder -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"' -ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server' +# The holder owns the tmux server, so clear loader, shell-control, and stale +# manager/session variables before the server process starts. +UnsetEnvironment=LD_PRELOAD BASH_ENV ENV +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=-/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] WantedBy=default.target diff --git a/packages/mosaic/framework/systemd/user/test-fleet-units.sh b/packages/mosaic/framework/systemd/user/test-fleet-units.sh index 041a325..6973a9c 100755 --- a/packages/mosaic/framework/systemd/user/test-fleet-units.sh +++ b/packages/mosaic/framework/systemd/user/test-fleet-units.sh @@ -5,6 +5,8 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service" AGENT="$SCRIPT_DIR/mosaic-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() { echo "FAIL: $*" >&2 @@ -14,16 +16,40 @@ fail() { [ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service" [ -f "$AGENT" ] || fail "missing mosaic-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 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket" 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 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh" -grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session" +if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then + 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 'EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env' "$INTERACTION" || fail "interaction service does not require per-agent config" -grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not validate before startup" +grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$INTERACTION" || \ + fail "interaction unit does not clear bootstrap environment before strict parsing" +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 systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || { @@ -32,4 +58,102 @@ if command -v systemd-analyze >/dev/null 2>&1; then } 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 +#include +#include +__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" < "$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" diff --git a/packages/mosaic/framework/tools/fleet/start-agent-session.sh b/packages/mosaic/framework/tools/fleet/start-agent-session.sh index e5c9f2c..60e73d7 100755 --- a/packages/mosaic/framework/tools/fleet/start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/start-agent-session.sh @@ -1,39 +1,207 @@ #!/usr/bin/env bash set -euo pipefail -AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} -# Absent socket ⇒ the LITERAL default tmux socket (no -L). The roster's -# 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} +# FCM-M2-001 boundary: only a roster-derived .env.generated projection and a +# separately parsed data-only .env.local can influence launch. Never source an +# environment file and never accept a command string from either file. -if [ -z "$AGENT_NAME" ]; then - echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2 - exit 64 -fi - -case "$MOSAIC_AGENT_REASONING" in - ''|low|medium|high) ;; - *) - echo "ERROR: MOSAIC_AGENT_REASONING must be low, medium, or high" >&2 - exit 64 +MODE=launch +case "${1:-}" in + --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 +} + +hash_value() { + printf '%s' "$1" | sha256sum | awk '{print $1}' +} + +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 +} + +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 +} + +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 echo "ERROR: tmux is required" >&2 exit 69 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() { if [ -n "$MOSAIC_TMUX_SOCKET" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" "$@" @@ -42,140 +210,90 @@ _tmux() { 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 echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}" exit 0 fi -if [ -z "$MOSAIC_AGENT_COMMAND" ]; then - # Map the roster's per-agent model_hint to `--model` so workers launch on the - # configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset. - MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}" -fi +# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under +# that home. Derive the pane home from the canonical path when available so an +# inherited pane/session HOME cannot become runtime authority. +PANE_HOME=$HOME +case "$MOSAIC_HOME" in + */.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() { 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 local npm_prefix 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 + candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin") - candidates+=("$HOME/.npm-global/bin") - candidates+=("$HOME/.local/bin") - - local prefix="" + local prefix="" dir for dir in "${candidates[@]}"; do [ -d "$dir" ] || continue - if [ -z "$prefix" ]; then - prefix="$dir" - else - case ":${prefix}:" in - *":${dir}:"*) ;; # already in our prefix — skip - *) prefix="${prefix}:${dir}" ;; - esac - fi + case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac done - printf '%s' "$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/.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[""].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() { local workdir="$1" - # The path claude keys on is the resolved cwd it is launched in. - 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 resolved + resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir" local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}" claude_json="${claude_json:-$HOME/.claude.json}" - - 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' + command -v python3 >/dev/null 2>&1 || return 1 + MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY' import json, os, sys, tempfile cj = os.environ["MOSAIC_CJ"] d = os.environ["MOSAIC_TRUST_DIR"] @@ -184,22 +302,19 @@ try: if not isinstance(data, dict): data = {} except Exception: - # Never corrupt an unreadable/partial file — bail without writing. sys.exit(2) projects = data.setdefault("projects", {}) entry = projects.get(d) if not isinstance(entry, dict): entry = {} projects[d] = entry -if entry.get("hasTrustDialogAccepted") is True: - sys.exit(0) # already trusted — nothing to do entry["hasTrustDialogAccepted"] = True tmp_dir = os.path.dirname(cj) or "." fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.") try: with os.fdopen(fd, "w") as f: json.dump(data, f, indent=2) - os.replace(tmp, cj) # atomic + os.replace(tmp, cj) except Exception: try: os.unlink(tmp) @@ -207,56 +322,56 @@ except Exception: pass sys.exit(3) PY - } - if command -v flock >/dev/null 2>&1; then - ( flock 9; _seed ) 9>"$lock" 2>/dev/null || _seed - else - _seed - fi } -case "$MOSAIC_AGENT_RUNTIME" in - claude) - _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" \ - || echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2 - ;; -esac +if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then + _ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \ + echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2 +fi -# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ──── +LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME") +if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi +if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi + +# The tmux holder owns a named server. Explicitly clear the pane environment +# 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" \ - bash -c "$PANE_SHELL_SNIPPET" + "${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}" -# ── Resolve the pane PID (retry briefly to let the session initialise) ──────── PANE_PID="" 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 sleep 0.2 done -# ── Spawn the heartbeat sidecar (detached, best-effort) ────────────────────── -# The sidecar writes ~/.config/mosaic/fleet/run/.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() { - local agent="$1" - local pane_pid="$2" - local run_dir="$3" - local interval="$4" + local agent="$1" pane_pid="$2" run_dir="$3" interval="$4" local hb_file="${run_dir}/${agent}.hb" - 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 sidecar_script=$(printf \ - '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=%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_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 setsid bash -c "$sidecar_script" /dev/null 2>&1 & else @@ -266,7 +381,6 @@ _start_heartbeat_sidecar() { } if [ -n "$PANE_PID" ]; then - # Guard: do not let sidecar startup failures abort the launcher (set -e). _start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \ "$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \ echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2 diff --git a/packages/mosaic/framework/tools/fleet/start-interaction-service.sh b/packages/mosaic/framework/tools/fleet/start-interaction-service.sh index 4efc539..b972b6a 100755 --- a/packages/mosaic/framework/tools/fleet/start-interaction-service.sh +++ b/packages/mosaic/framework/tools/fleet/start-interaction-service.sh @@ -9,11 +9,7 @@ fail() { } [ -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" -exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" "$AGENT_NAME" +# The shared launcher strictly validates the generated/local data boundary +# before it applies this interaction service's pinned profile checks. +exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" --interaction "$AGENT_NAME" diff --git a/packages/mosaic/framework/tools/fleet/start-tmux-holder.sh b/packages/mosaic/framework/tools/fleet/start-tmux-holder.sh new file mode 100755 index 0000000..1422647 --- /dev/null +++ b/packages/mosaic/framework/tools/fleet/start-tmux-holder.sh @@ -0,0 +1,64 @@ +#!/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 diff --git a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh index 0e83ecc..5d1c27d 100755 --- a/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh +++ b/packages/mosaic/framework/tools/fleet/test-start-agent-session.sh @@ -3,373 +3,410 @@ set -euo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd) START="$SCRIPT_DIR/start-agent-session.sh" -SOCKET="mosaic-agent-test-$RANDOM-$$" -AGENT="agent-$RANDOM" -WORKDIR=$(mktemp -d) - -# 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 -} +INTERACTION_START="$SCRIPT_DIR/start-interaction-service.sh" +ROOT=$(mktemp -d) +FAKE_BIN=$(mktemp -d) +TMUX_CALLS=$(mktemp) +trap 'rm -rf "$ROOT" "$FAKE_BIN" "$TMUX_CALLS"' EXIT fail() { echo "FAIL: $*" >&2 exit 1 } -# ── 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" < "$FAKE_BIN/tmux" <<'SHIM' #!/usr/bin/env bash -# Fake tmux: record new-session args; report has-session as missing. -subcmd="\$3" # argv: tmux -L ... -if [ "\$subcmd" = "has-session" ]; then - exit 1 # session not found → script will attempt new-session -fi -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 -exit 0 +set -euo pipefail +printf '%s\0' "$@" >> "${MOSAIC_TEST_TMUX_CALLS:?}" +args=("$@") +index=0 +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 + done + fi + exit 0 + ;; + *) exit 0 ;; +esac SHIM chmod +x "$FAKE_BIN/tmux" -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" < "$FAKE_BIN/mosaic" <<'SHIM' #!/usr/bin/env bash -subcmd="\$3" -if [ "\$subcmd" = "has-session" ]; then exit 1; fi -if [ "\$subcmd" = "new-session" ]; then - printf '%s\n' "\$@" > "$TMUX_ARGS_FILE2" - exit 0 +set -euo pipefail +env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment" +SHIM +chmod +x "$FAKE_BIN/mosaic" + +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" < "$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 -exit 0 -SHIM2 -chmod +x "$FAKE_BIN2/tmux" +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection" +echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing" -SOCKET4="mosaic-agent-test4-$RANDOM-$$" -AGENT4="agent4-$RANDOM" -WORKDIR4=$(mktemp -d) -CLEANUP_DIRS+=("$WORKDIR4") - -# MOSAIC_RUNTIME_BIN points to a non-existent dir so prefix will be empty; -# .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" < "$TMUX_ARGS_FILE5" - exit 0 +: > "$TMUX_CALLS" +HOME_SYMLINK_PARENT="$ROOT/symlink-parent" +write_generated "$HOME_SYMLINK_PARENT" "coder-symlink-parent" +mv "$HOME_SYMLINK_PARENT/fleet/agents" "$HOME_SYMLINK_PARENT/private-agents" +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 + fail "generated file under a symlinked parent was accepted" fi -if [ "\$subcmd" = "list-panes" ]; then - # Return empty: no sidecar spawned (heartbeat is not the focus of this test). - echo "" - exit 0 -fi -exit 0 -SHIM5 -chmod +x "$FAKE_BIN5/tmux" +[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection" +echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing" -SOCKET5="mosaic-agent-test5-$RANDOM-$$" -AGENT5="agent5-$RANDOM" -WORKDIR5=$(mktemp -d) -CLEANUP_DIRS+=("$WORKDIR5") -CLEANUP_SOCKETS+=("$SOCKET5") +# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A +# 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" -# FAKE_RUNTIME_BIN5 is deliberately placed on the LAUNCHER PATH so that the -# old (buggy) code would have skipped it. The correct code must still include -# 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" + 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 -all_args5=$(cat "$TMUX_ARGS_FILE5" 2>/dev/null || true) -rm -f "$TMUX_ARGS_FILE5" -rm -rf "$WORKDIR5" + if [ "$hazard" = symlink ]; then + local target="${node}-target" + mv "$node" "$target" + ln -s "$target" "$node" + else + chmod 777 "$node" + fi -echo "--- test 5: launcher-PATH candidate must still appear in pane export ---" -echo "$all_args5" -echo "--- end test 5 args ---" + : > "$TMUX_CALLS" + if output=$(run_start "$home" "$agent" 2>&1); then + fail "${hazard} $ancestor was accepted" + fi + [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection" + [ ! -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 +} -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)) +for managed_ancestor in MOSAIC_HOME MOSAIC_HOME/fleet MOSAIC_HOME/fleet/agents; do + assert_managed_ancestor_rejected "$managed_ancestor" symlink + assert_managed_ancestor_rejected "$managed_ancestor" group-world-writable done -[ -f "$HB_FILE" ] || fail "test6: heartbeat file not written at $HB_FILE within 5s" +# A local file cannot shadow any roster-derived generated key. Validation must +# 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 -hb_content=$(cat "$HB_FILE") -echo "--- test 6: heartbeat file content ---" -echo "$hb_content" -echo "--- end test 6 ---" +# Arbitrary command compatibility is quarantined/rejected as data. Diagnostics +# may name the key and hash but must never echo the privileged command text. +: > "$TMUX_CALLS" +HOME_COMMAND="$ROOT/command" +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 -# Verify required fields are present. -echo "$hb_content" | grep -qE '^ts=[0-9]{4}-[0-9]{2}-[0-9]{2}T' || \ - fail "test6: heartbeat ts field missing or malformed" -echo "$hb_content" | grep -qE '^pid=[0-9]+' || \ - fail "test6: heartbeat pid field missing or malformed" -echo "$hb_content" | grep -qF 'status=ok' || \ - fail "test6: heartbeat status=ok missing" +# Group/world-readable local input is not trusted even when its syntax is safe. +: > "$TMUX_CALLS" +HOME_PERMS="$ROOT/perms" +write_generated "$HOME_PERMS" "coder3" +printf 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n' > "$HOME_PERMS/fleet/agents/coder3.env.local" +chmod 644 "$HOME_PERMS/fleet/agents/coder3.env.local" +if output=$(run_start "$HOME_PERMS" coder3 2>&1); then + 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" -# ── Test 7: heartbeat sidecar — targets correct .hb path per agent name ──────── -# -# Uses the fake-tmux shim approach (like tests 3-5) to capture the sidecar -# invocation without needing a real session. A fake setsid shim records its -# arguments so we can assert the sidecar script targets the expected .hb path -# and uses the configured interval. +# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and +# computed PATH only. The pane command itself must not carry loader, shell +# control, arbitrary sentinel, or stale bootstrap variables. +: > "$TMUX_CALLS" +HOME_PANE_BOUNDARY="$ROOT/pane-boundary/.config/mosaic" +write_generated "$HOME_PANE_BOUNDARY" "coder-pane-boundary" +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 -FAKE_BIN7=$(mktemp -d) -FAKE_RUNTIME_BIN7=$(mktemp -d) -SETSID_ARGS_FILE=$(mktemp) -HB_RUN_DIR7=$(mktemp -d) -CLEANUP_DIRS+=("$FAKE_BIN7" "$FAKE_RUNTIME_BIN7" "$HB_RUN_DIR7") +after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1) +[ -n "$after_pane_env" ] || fail "pane command did not use absolute env" +printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \ + fail "pane command did not clear its environment" +pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment") +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 -AGENT7="my-fleet-agent-$RANDOM" -INTERVAL7="42" +write_interaction_generated() { + local home="$1" + 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" < "$FAKE_BIN7/tmux" < argument for inspection, then -# background an actual bash subshell so disown succeeds in the caller. -cat > "$FAKE_BIN7/setsid" <<'SETSID_SHIM' -#!/usr/bin/env bash -# argv: setsid bash -c -# Record the full argument list to the capture file, then exit cleanly. -printf '%s\0' "$@" > __SETSID_ARGS_FILE__ -exit 0 -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" +write_heartbeat_local() { + local home="$1" + local agent="$2" + mkdir -p "$home/run" + cat > "$home/fleet/agents/$agent.env.local" </dev/null && return 0 + sleep 0.1 + done + fail "heartbeat sidecar did not resume after native marker became stale or absent" +} -PATH="$FAKE_BIN7:$PATH" \ -MOSAIC_TMUX_SOCKET="$SOCKET7" \ -MOSAIC_AGENT_WORKDIR="$WORKDIR7" \ -MOSAIC_AGENT_RUNTIME="pi" \ -MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN7" \ -MOSAIC_AGENT_COMMAND="mosaic yolo pi" \ -MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR7" \ -MOSAIC_HEARTBEAT_INTERVAL="$INTERVAL7" \ - "$START" "$AGENT7" +# A fresh Pi-native marker is authoritative: the shell sidecar may start but +# must not overwrite Pi's busy/ok/model heartbeat. It must resume only when +# the marker is stale or absent. +HOME_NATIVE_FRESH="$ROOT/native-fresh" +write_generated "$HOME_NATIVE_FRESH" "coder-native-fresh" +write_heartbeat_local "$HOME_NATIVE_FRESH" "coder-native-fresh" +FRESH_HB="$HOME_NATIVE_FRESH/run/coder-native-fresh.hb" +printf 'ts=native\npid=1\nstatus=busy\nmodel=authoritative-model\n' > "$FRESH_HB" +touch "$FRESH_HB.native" +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" -# Give the background setsid shim a moment to finish writing the capture file. -sleep 0.5 +HOME_NATIVE_STALE="$ROOT/native-stale" +write_generated "$HOME_NATIVE_STALE" "coder-native-stale" +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" -setsid_args=$(cat "$SETSID_ARGS_FILE" 2>/dev/null | tr '\0' '\n' || true) -rm -f "$SETSID_ARGS_FILE" -rm -rf "$WORKDIR7" +HOME_NATIVE_ABSENT="$ROOT/native-absent" +write_generated "$HOME_NATIVE_ABSENT" "coder-native-absent" +write_heartbeat_local "$HOME_NATIVE_ABSENT" "coder-native-absent" +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" -echo "--- test 7: captured setsid args ---" -echo "$setsid_args" -echo "--- end test 7 ---" +# The interaction wrapper delegates to the shared strict parser before applying +# its pinned policy, so malformed projection data wins over profile diagnostics. +: > "$TMUX_CALLS" +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" -# The sidecar script (bash -c