diff --git a/docs/PRD.md b/docs/PRD.md index 77ccd609..454f66cb 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -146,6 +146,36 @@ lands. M0 consists only of these normative requirements, the complete task DAG, documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR. +### Fleet git identity launch propagation (#1043) + +#### Problem and objective + +A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks +`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which +blocks repository operations on fail-closed estates and can fall through to an unrelated identity on +estates where that refusal is not active. The objective is to make Git identity a deterministic, +roster-derived part of the generated launch projection and prove it reaches the launched process. + +#### Normative requirements + +1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare + `MOSAIC_GIT_IDENTITY=`; a differing or unsafe identity SHALL fail closed before + tmux launch. +2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the + generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process. +3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection + against the launched process environment. Source-text/string-presence assertions are insufficient. +4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that + removes Git-identity pane propagation and makes the behavioral test fail. + +#### Acceptance criteria + +1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated + environment projection, including the roster-derived Git identity. +2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created. +3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates, + independent review, and the required RED/green/R7 evidence are recorded before push. + --- ## Exact Cross-Harness Fleet Communications Contract (#766) diff --git a/docs/fleet/FLEET-LAUNCH.md b/docs/fleet/FLEET-LAUNCH.md index 758a1e99..419372e7 100644 --- a/docs/fleet/FLEET-LAUNCH.md +++ b/docs/fleet/FLEET-LAUNCH.md @@ -5,14 +5,14 @@ Generated environment files are rebuildable projections, not an operator-editabl ## Launch chain -| 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. | +| Layer | Responsibility | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. | +| 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. | 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, @@ -24,6 +24,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf ```dotenv MOSAIC_AGENT_NAME= +MOSAIC_GIT_IDENTITY= MOSAIC_AGENT_CLASS= MOSAIC_AGENT_RUNTIME= MOSAIC_AGENT_MODEL= @@ -33,8 +34,10 @@ MOSAIC_AGENT_WORKDIR= MOSAIC_TMUX_SOCKET= ``` -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. +`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing +split runtime and repository identity authority. 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. diff --git a/docs/fleet/concepts/generated-env-launch-chain.md b/docs/fleet/concepts/generated-env-launch-chain.md index 65cb582a..fc221b8f 100644 --- a/docs/fleet/concepts/generated-env-launch-chain.md +++ b/docs/fleet/concepts/generated-env-launch-chain.md @@ -3,11 +3,12 @@ The launcher consumes validated data, not shell configuration. 1. Read and validate the canonical roster. -2. Render deterministic .env.generated data from that roster. +2. Render deterministic .env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name. 3. Parse optional .env.local through a strict allowlist. 4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides. -5. Derive the runtime command from validated runtime/model/reasoning data. -6. Target only the exact configured tmux socket and roster session after ownership checks. +5. Reject a Git identity that is unsafe or differs from the generated agent name. +6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary. +7. Target only the exact configured tmux socket and roster session after ownership checks. ## File precedence and ownership diff --git a/docs/fleet/reference/generated-env-boundary.md b/docs/fleet/reference/generated-env-boundary.md index 5894da5e..7d97e39b 100644 --- a/docs/fleet/reference/generated-env-boundary.md +++ b/docs/fleet/reference/generated-env-boundary.md @@ -35,6 +35,7 @@ values, credential material, or command text. ```dotenv MOSAIC_AGENT_NAME= +MOSAIC_GIT_IDENTITY= MOSAIC_AGENT_CLASS= MOSAIC_AGENT_RUNTIME= MOSAIC_AGENT_MODEL= @@ -44,8 +45,9 @@ 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 +`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate +operator-controlled identity authority. 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. diff --git a/docs/scratchpads/1043-pane-git-identity.md b/docs/scratchpads/1043-pane-git-identity.md new file mode 100644 index 00000000..8e54df07 --- /dev/null +++ b/docs/scratchpads/1043-pane-git-identity.md @@ -0,0 +1,229 @@ +# #1043 — Fleet pane git-identity propagation + +## Objective + +Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison. + +## Tracking + +- External issue: `mosaicstack/stack#1043` +- Branch: `fix/1043-pane-git-identity` +- Coordinator: `tl-mosaic` +- `docs/TASKS.md`: read-only by project worker contract; not modified. + +## Constraints + +- RED-first bug reproducer is mandatory. +- R7 delete-the-subject mutation must turn the behavioral test red. +- Assert launched-process environment, not source text. +- One push only; do not poll CI after push. +- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence. +- Do not modify a live host launcher or obtain/copy another credential. +- Self-post the PR, verify provider attribution, then stop. +- Final status wording: `believed-fixed, pending jarvis validation`. + +## Scope inventory + +Re-derived against `origin/main` at `85d2108e`: + +- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh` +- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` +- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts` +- Roster projection producers: + - `packages/mosaic/src/commands/fleet.ts` + - `packages/mosaic/src/fleet/fleet-reconciler.ts` + - `packages/mosaic/src/fleet/fleet-agent-crud.ts` + - `packages/mosaic/src/fleet/v1-v2-migration.ts` +- Contract and producer tests discovered by repository search. +- Generated-environment operator/developer docs and their executable documentation contract test. + +Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior. + +## Plan + +1. Add the process-environment set-comparison regression first and record RED. +2. Add roster-derived `MOSAIC_GIT_IDENTITY=` to the complete generated projection contract. +3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment. +4. Update affected projection tests and generated-environment docs. +5. Run focused and baseline gates. +6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN. +7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling. + +## Budget + +No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor. + +## Evidence log + +### TDD and mutation evidence + +- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity. +- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`. +- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`. +- R7 restoration: restored that launch entry; the same test returned green. +- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy. + +### Situational and focused tests + +- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity. +- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green. +- Focused Mosaic Vitest set: 6 files, 311 tests — green. +- `bash -n` on changed shell files — green. +- `git diff --check` — green. + +### Baseline gates + +- `pnpm typecheck` — 45/45 tasks green. +- `pnpm lint` — 25/25 tasks green. +- `pnpm format:check` — green. +- `pnpm test:checkout` — green. +- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase. +- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates: + 1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr; + 2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`). + No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter. + +### Independent review + +- Codex code review first pass: request changes for missing shell rejection-path coverage. +- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call. +- Codex code re-review: **approve**, no findings, confidence 0.88. +- Codex security review: risk `none`, no findings, confidence 0.97. + +### Acceptance criteria mapping + +| Acceptance criterion | Evidence | +| --- | --- | +| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above | +| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests | +| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly | + +### Documentation checklist + +- PRD updated with #1043 requirements and acceptance criteria. +- Fleet launch runbook, generated-env concept, and generated-env reference updated. +- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies. +- `docs/TASKS.md` remains unmodified per its single-writer project contract. + +## Round 2 — PR #1073 review 97 remediation + +### Review blocker + +The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression. + +### RED-first and canonical wiring + +1. Removed the suite's signed exclusion before adding a CI execution path. +2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18. +3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain. +4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition. + +### Workflow-level R7 + +- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`. +- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain. +- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`. +- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort. +- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical. + +### Workflow population + +- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`). +- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses. +- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change. + +### Independent remediation review + +- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install. +- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged. +- Codex re-review: **APPROVE**, confidence 0.93, no findings. + +### Vitest denominator reconciliation + +The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees: + +- generated environment boundary: 24 +- fleet documentation: 23 +- Tess service profile: 6 +- fleet regen command: 27 +- fleet agent CRUD command: 22 +- fleet command: 209 +- total: **311** + +Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic. + +## Round 3 — Alpine stale-marker portability + +### Objective and plan + +- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion. +- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review. +- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling. +- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes. + +### RED-first evidence + +Before the fix, the canonical CI image command +`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` +exited 1 at the stale-marker setup with exact BusyBox output +`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location. + +### Root cause and fix + +The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata. + +### Structural pattern + +This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening. + +### GREEN and quality evidence + +- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`. +- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces. +- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed. +- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN. +- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings. +- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings. + +### Re-derived inventory and denominators + +- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization. +- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path. +- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`). +- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets. + +## Round 4 — bound stale-marker observation + +### Objective and plan + +- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window. +- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale. +- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image. +- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling. +- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged. + +### Root cause and bounded observation + +The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion. + +The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed. + +### Two-mutant RED / restored GREEN + +All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`: + +1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`. +2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls. +3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`. + +### Re-derived inventory + +- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty. +- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path. +- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds. +- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation. + +## Residual risk + +- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events. +- Canonical CI result is pending and will not be polled by this seat. diff --git a/packages/mosaic/framework/systemd/user/test-fleet-units.sh b/packages/mosaic/framework/systemd/user/test-fleet-units.sh index 6973a9ce..91b4a34e 100755 --- a/packages/mosaic/framework/systemd/user/test-fleet-units.sh +++ b/packages/mosaic/framework/systemd/user/test-fleet-units.sh @@ -112,6 +112,7 @@ EOF chmod 700 "$AGENT_HOME/fleet/agents" cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" < "${MOSAIC_HOME:?}/fleet/pane-environment" SHIM chmod +x "$FAKE_BIN/mosaic" +# Freeze numeric epoch reads only when a test arm supplies an observation bound. +# Formatting reads still use the real BusyBox/POSIX date implementation. +cat > "$FAKE_BIN/date" <<'SHIM' +#!/usr/bin/env bash +set -euo pipefail +if [ -n "${MOSAIC_TEST_FIXED_EPOCH:-}" ] && [ "${1:-}" = '+%s' ]; then + printf '%s\n' "$MOSAIC_TEST_FIXED_EPOCH" + exit 0 +fi +exec /bin/date "$@" +SHIM +chmod +x "$FAKE_BIN/date" + write_generated() { local home="$1" local agent="$2" @@ -71,6 +84,7 @@ write_generated() { chmod 600 "$home/fleet/run/holder-owner" cat > "$home/fleet/agents/$agent.env.generated" < "$home/fleet/agents/$agent.env.local" + chmod 600 "$home/fleet/agents/$agent.env.local" + ;; + *) fail "unknown Git identity rejection case: $case_name" ;; + esac + chmod 600 "$generated" + + : > "$TMUX_CALLS" + if output=$(run_start "$home" "$agent" 2>&1); then + fail "Git identity case $case_name was accepted" + fi + [ ! -s "$TMUX_CALLS" ] || fail "tmux ran before Git identity $case_name rejection" + echo "$output" | grep -qF "code=$expected_code" || \ + fail "Git identity $case_name diagnostic omitted code $expected_code" +} + +assert_git_identity_rejected missing missing-key +assert_git_identity_rejected unsafe unsafe-git-identity +assert_git_identity_rejected mismatch git-identity-mismatch +assert_git_identity_rejected local-shadow generated-key-shadow + # The generated-file parent is a security boundary too: even a private regular # file is untrusted if its parent can be replaced or written by another user. # Validation must happen before fake tmux receives even a has-session call. @@ -274,6 +325,18 @@ after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cu 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") +# Exercise the repository launcher at $START, not the independently installed +# host copy. Set-compare every declared generated projection entry with the +# launched process environment so a newly declared identity cannot be omitted +# by a hand-maintained per-variable assertion. +declared_generated_environment=$(sort "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.generated") +missing_or_changed_generated_environment=$(comm -23 \ + <(printf '%s\n' "$declared_generated_environment") \ + <(printf '%s\n' "$pane_environment" | sort)) +if [ -n "$missing_or_changed_generated_environment" ]; then + missing_or_changed_keys=$(printf '%s\n' "$missing_or_changed_generated_environment" | cut -d= -f1 | paste -sd, -) + fail "runtime pane omitted or changed generated environment keys: $missing_or_changed_keys" +fi 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" @@ -290,6 +353,7 @@ write_interaction_generated() { chmod 600 "$home/fleet/run/holder-owner" cat > "$home/fleet/agents/$agent.env.generated" < "$STALE_HB" -touch -d '10 seconds ago' "$STALE_HB.native" -MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale +touch -t 200001010000.00 "$STALE_HB.native" +# Hold the sidecar's observation epoch constant: assertion runtime must not age +# a fresh-marker mutant into the stale state that this fixture must distinguish. +STALE_OBSERVATION_EPOCH=$(date +%s) +MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \ + MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale wait_for_sidecar_status "$STALE_HB" HOME_NATIVE_ABSENT="$ROOT/native-absent" @@ -381,7 +449,7 @@ echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use sh : > "$TMUX_CALLS" HOME_INTERACTION_POLICY="$ROOT/interaction-policy" write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy" -perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \ +sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \ "$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated" if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then fail "interaction wrapper accepted a policy-incompatible projection" diff --git a/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt b/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt index 49186d6f..f5d210e3 100644 --- a/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt +++ b/packages/mosaic/framework/tools/quality/test-enumeration-exclusions.txt @@ -32,7 +32,6 @@ packages/mosaic/framework/tools/tmux/test-send-message-socket.sh | requires a re packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires real tmux-pane fixtures on a throwaway socket; CI image ships no tmux; #1017 burndown (same condition as its sibling) # --- single-suite directories: unmeasured in CI --- -packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | unmeasured in CI image; stubs tmux via a fake bin dir, likely CI-fit; #1017 burndown packages/mosaic/framework/tools/glpi/test-list-http-status.sh | unmeasured in CI image; stub-based (#807 regression harness), likely CI-fit; #1017 burndown packages/mosaic/framework/tools/orchestrator/test-board-roll.sh | unmeasured in CI image; file-fixture based, likely CI-fit; #1017 burndown packages/mosaic/framework/tools/woodpecker/test-ci-wait-exit-matrix.sh | unmeasured in CI image; drives ci-wait.sh against a stub pipeline-status.sh, likely CI-fit; #1017 burndown diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 631b4a88..bb128c06 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -25,7 +25,7 @@ "lint": "eslint src", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests && pnpm run test:framework-shell", - "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh" + "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh" }, "dependencies": { "@mosaicstack/brain": "workspace:*", diff --git a/packages/mosaic/src/commands/fleet-regen-command.spec.ts b/packages/mosaic/src/commands/fleet-regen-command.spec.ts index 575d7fa4..6a799b5c 100644 --- a/packages/mosaic/src/commands/fleet-regen-command.spec.ts +++ b/packages/mosaic/src/commands/fleet-regen-command.spec.ts @@ -131,13 +131,14 @@ async function exists(path: string): Promise { } describe('projectRosterV2AgentGeneratedEnv', (): void => { - it('maps a roster-v2 agent to exactly the eight generated projection keys', (): void => { + it('maps a roster-v2 agent to exactly the nine generated projection keys', (): void => { const roster = parseRosterV2(rosterYaml, 'yaml'); const agent = roster.agents.find((candidate) => candidate.name === 'coder0'); expect(agent).toBeDefined(); const values = projectRosterV2AgentGeneratedEnv(roster, agent!); expect(values).toEqual({ MOSAIC_AGENT_NAME: 'coder0', + MOSAIC_GIT_IDENTITY: 'coder0', MOSAIC_AGENT_CLASS: 'code', MOSAIC_AGENT_RUNTIME: 'pi', MOSAIC_AGENT_MODEL: 'gpt-5.6-sol', diff --git a/packages/mosaic/src/commands/fleet.spec.ts b/packages/mosaic/src/commands/fleet.spec.ts index 9aaaf055..f0a468da 100644 --- a/packages/mosaic/src/commands/fleet.spec.ts +++ b/packages/mosaic/src/commands/fleet.spec.ts @@ -422,6 +422,7 @@ describe('fleet roster parsing', () => { expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe( [ 'MOSAIC_AGENT_NAME=coder0', + 'MOSAIC_GIT_IDENTITY=coder0', // Reflects the roster's canonicalized compatibility class (A3a). 'MOSAIC_AGENT_CLASS=code', 'MOSAIC_AGENT_RUNTIME=codex', @@ -3799,6 +3800,7 @@ describe('fleet add command', () => { 'utf8', ); expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0'); + expect(envContent).toContain('MOSAIC_GIT_IDENTITY=coder0'); expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex'); }); diff --git a/packages/mosaic/src/commands/fleet.ts b/packages/mosaic/src/commands/fleet.ts index c344660a..9e392604 100644 --- a/packages/mosaic/src/commands/fleet.ts +++ b/packages/mosaic/src/commands/fleet.ts @@ -484,6 +484,7 @@ function generateAgentEnvValues( const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory; return { MOSAIC_AGENT_NAME: agent.name, + MOSAIC_GIT_IDENTITY: agent.name, MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_MODEL: agent.modelHint ?? '', diff --git a/packages/mosaic/src/fleet/fleet-agent-crud.ts b/packages/mosaic/src/fleet/fleet-agent-crud.ts index 9795a406..51bf7ed5 100644 --- a/packages/mosaic/src/fleet/fleet-agent-crud.ts +++ b/packages/mosaic/src/fleet/fleet-agent-crud.ts @@ -358,6 +358,7 @@ function generatedValues( ): Readonly> { return { MOSAIC_AGENT_NAME: agent.name, + MOSAIC_GIT_IDENTITY: agent.name, MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_MODEL: agent.model, diff --git a/packages/mosaic/src/fleet/fleet-documentation.spec.ts b/packages/mosaic/src/fleet/fleet-documentation.spec.ts index 2e5a108e..3254f19c 100644 --- a/packages/mosaic/src/fleet/fleet-documentation.spec.ts +++ b/packages/mosaic/src/fleet/fleet-documentation.spec.ts @@ -380,7 +380,7 @@ const COMMAND_RECORDS: Readonly> = { const DATA_PROFILE_BODIES: Readonly> = { 'DATA.DOTENV.FLEET_LAUNCH': - 'MOSAIC_AGENT_NAME=\nMOSAIC_AGENT_CLASS=\nMOSAIC_AGENT_RUNTIME=\nMOSAIC_AGENT_MODEL=\nMOSAIC_AGENT_REASONING=\nMOSAIC_AGENT_TOOL_POLICY=\nMOSAIC_AGENT_WORKDIR=\nMOSAIC_TMUX_SOCKET=', + 'MOSAIC_AGENT_NAME=\nMOSAIC_GIT_IDENTITY=\nMOSAIC_AGENT_CLASS=\nMOSAIC_AGENT_RUNTIME=\nMOSAIC_AGENT_MODEL=\nMOSAIC_AGENT_REASONING=\nMOSAIC_AGENT_TOOL_POLICY=\nMOSAIC_AGENT_WORKDIR=\nMOSAIC_TMUX_SOCKET=', 'DATA.TEXT_TABLE.FLEET_TASKS': '| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |', 'DATA.TEXT_DIAGRAM.BACKLOG_FLOW': @@ -406,7 +406,7 @@ const DATA_PROFILE_BODIES: Readonly> = { 'DATA.JSON.MUTATION_RESULT': '{\n "applied": false,\n "authoritativeRoster": "committed",\n "projections": "incomplete",\n "recovery": {\n "code": "projection-apply-failed",\n "action": "regenerate-projections-from-roster"\n }\n}', 'DATA.DOTENV.GENERATED_ENV': - 'MOSAIC_AGENT_NAME=\nMOSAIC_AGENT_CLASS=\nMOSAIC_AGENT_RUNTIME=\nMOSAIC_AGENT_MODEL=\nMOSAIC_AGENT_REASONING=\nMOSAIC_AGENT_TOOL_POLICY=\nMOSAIC_AGENT_WORKDIR=\nMOSAIC_TMUX_SOCKET=', + 'MOSAIC_AGENT_NAME=\nMOSAIC_GIT_IDENTITY=\nMOSAIC_AGENT_CLASS=\nMOSAIC_AGENT_RUNTIME=\nMOSAIC_AGENT_MODEL=\nMOSAIC_AGENT_REASONING=\nMOSAIC_AGENT_TOOL_POLICY=\nMOSAIC_AGENT_WORKDIR=\nMOSAIC_TMUX_SOCKET=', 'DATA.YAML.ROSTER_FIELDS': 'version: 2\ngeneration: 1\ntransport: tmux\ntmux:\n socket_name: mosaic-fleet\n holder_session: _holder\ndefaults:\n working_directory: ~/src\n runtime: pi\nruntimes:\n pi:\n reset_command: /new\nagents:\n - name: coder0\n alias: Coder 0\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true', }; @@ -922,8 +922,8 @@ describe('fleet operator documentation', (): void => { ); expect( surfaces.filter((surface): boolean => surface.category === 'InlineLiteral'), - ).toHaveLength(858); - expect(surfaces).toHaveLength(882); + ).toHaveLength(863); + expect(surfaces).toHaveLength(887); const rosterSource = await readFile(join(fleetDocs, 'examples', 'roster-v2.yaml'), 'utf8'); const auxiliary: CodeSurface = { diff --git a/packages/mosaic/src/fleet/fleet-reconciler.ts b/packages/mosaic/src/fleet/fleet-reconciler.ts index 732c878d..21578e25 100644 --- a/packages/mosaic/src/fleet/fleet-reconciler.ts +++ b/packages/mosaic/src/fleet/fleet-reconciler.ts @@ -597,6 +597,7 @@ export function projectRosterV2AgentGeneratedEnv( ): Readonly> { return { MOSAIC_AGENT_NAME: agent.name, + MOSAIC_GIT_IDENTITY: agent.name, MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_MODEL: agent.model, diff --git a/packages/mosaic/src/fleet/generated-env-boundary.spec.ts b/packages/mosaic/src/fleet/generated-env-boundary.spec.ts index c0c8a96e..bf76a70b 100644 --- a/packages/mosaic/src/fleet/generated-env-boundary.spec.ts +++ b/packages/mosaic/src/fleet/generated-env-boundary.spec.ts @@ -22,6 +22,7 @@ import { const generatedValues = { MOSAIC_AGENT_NAME: 'coder0', + MOSAIC_GIT_IDENTITY: 'coder0', MOSAIC_AGENT_CLASS: 'code', MOSAIC_AGENT_RUNTIME: 'pi', MOSAIC_AGENT_MODEL: 'openai-codex/gpt-5.6-sol', @@ -45,6 +46,7 @@ describe('generated fleet agent environment boundary', (): void => { expect(renderGeneratedAgentEnvironment(generatedValues)).toBe( [ 'MOSAIC_AGENT_NAME=coder0', + 'MOSAIC_GIT_IDENTITY=coder0', 'MOSAIC_AGENT_CLASS=code', 'MOSAIC_AGENT_RUNTIME=pi', 'MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol', @@ -78,6 +80,22 @@ describe('generated fleet agent environment boundary', (): void => { expect(String(error)).toMatch(/key=.*sha256=/); }); + it.each([ + ['unsafe-git-identity', 'other/identity'], + ['git-identity-mismatch', 'reviewer0'], + ])('rejects %s before any launch consumer can use it', (code: string, identity: string): void => { + expect((): void => { + renderGeneratedAgentEnvironment({ + ...generatedValues, + MOSAIC_GIT_IDENTITY: identity, + }); + }).toThrow( + expect.objectContaining({ + diagnostic: expect.objectContaining({ code, key: 'MOSAIC_GIT_IDENTITY' }), + }), + ); + }); + it('rejects unsafe generated paths before any launch consumer can use them', (): void => { expect((): void => { renderGeneratedAgentEnvironment({ diff --git a/packages/mosaic/src/fleet/generated-env-boundary.ts b/packages/mosaic/src/fleet/generated-env-boundary.ts index cd849440..54d68415 100644 --- a/packages/mosaic/src/fleet/generated-env-boundary.ts +++ b/packages/mosaic/src/fleet/generated-env-boundary.ts @@ -73,6 +73,7 @@ export class AgentEnvBoundaryError extends Error { export const GENERATED_AGENT_ENV_KEYS = [ 'MOSAIC_AGENT_NAME', + 'MOSAIC_GIT_IDENTITY', 'MOSAIC_AGENT_CLASS', 'MOSAIC_AGENT_RUNTIME', 'MOSAIC_AGENT_MODEL', @@ -402,6 +403,7 @@ function assertGeneratedValues(values: Readonly>): void { if (value === undefined) throw new AgentEnvBoundaryError('missing-key', key, ''); } const name = requiredGeneratedValue(values, 'MOSAIC_AGENT_NAME'); + const gitIdentity = requiredGeneratedValue(values, 'MOSAIC_GIT_IDENTITY'); const className = requiredGeneratedValue(values, 'MOSAIC_AGENT_CLASS'); const runtime = requiredGeneratedValue(values, 'MOSAIC_AGENT_RUNTIME'); const model = requiredGeneratedValue(values, 'MOSAIC_AGENT_MODEL'); @@ -412,6 +414,12 @@ function assertGeneratedValues(values: Readonly>): void { if (!AGENT_NAME.test(name)) throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', name); + if (!AGENT_NAME.test(gitIdentity)) { + throw new AgentEnvBoundaryError('unsafe-git-identity', 'MOSAIC_GIT_IDENTITY', gitIdentity); + } + if (gitIdentity !== name) { + throw new AgentEnvBoundaryError('git-identity-mismatch', 'MOSAIC_GIT_IDENTITY', gitIdentity); + } if (!POLICY_NAME.test(className)) { throw new AgentEnvBoundaryError('unsafe-class', 'MOSAIC_AGENT_CLASS', className); } diff --git a/packages/mosaic/src/fleet/v1-v2-migration.ts b/packages/mosaic/src/fleet/v1-v2-migration.ts index 69961d28..4f078b10 100644 --- a/packages/mosaic/src/fleet/v1-v2-migration.ts +++ b/packages/mosaic/src/fleet/v1-v2-migration.ts @@ -1405,6 +1405,7 @@ function generatedValues( ): Readonly> { return { MOSAIC_AGENT_NAME: agent.name, + MOSAIC_GIT_IDENTITY: agent.name, MOSAIC_AGENT_CLASS: agent.className, MOSAIC_AGENT_RUNTIME: agent.runtime, MOSAIC_AGENT_MODEL: agent.model,