Compare commits

...
Author SHA1 Message Date
peggy 7a2639d0f4 fix(#808): never borrow a session identity for non-tmux senders
ci/woodpecker/pr/ci Pipeline was successful
When MOSAIC_AGENT_NAME is unset and the sender has no tmux client context
($TMUX empty), agent-send.sh fell back to 'tmux display-message -p #S',
which outside tmux reports the LAST-ACTIVE session on the server. A
nameless sender was silently stamped as whichever seat touched tmux most
recently, forging that seat's identity in the preamble.

Incident (2026-08-20, host sb-it-1-dt): a nameless non-tmux sender was
stamped 'peggy', a live seat, on a message delivered TO peggy. Reproduced
end-to-end before the fix; capture in the seat evidence note.

Fix: the display-message fallback now runs only when $TMUX is set (inside
tmux it resolves the caller's own session — the case #808 built it for).
Outside tmux with no name, the label is host:unverified. Deliberate
identity remains available via -S. Chosen over refusing the send: nameless
senders are legitimate; the defect was borrowing an identity, not lacking
one.

Tests: suite made hermetic (TMUX controlled explicitly; results no longer
depend on the caller's context — previously tests 9/10 inherited the
caller's TMUX). New case 9b: no-tmux sender is labeled unverified and
never a borrowed name (red on original: host:local-agent borrowed from the
fake tmux server; green after). 19/19 in both caller contexts.
2026-08-20 15:41:48 -05:00
code-infra-01 6db0bead44 fix(#1327): sentinel-managed PATH block, default-home-only profile writes (#1330)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-infra-01 <[email protected]>
2026-08-20 20:20:33 +00:00
GhostfredGhost <>
c671290d77 docs: define main/next branch model, sequencing, responsibilities, and merge process (#1214) (#1215)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Ghost <>
2026-08-20 20:19:39 +00:00
code-infra-01andfred 6a9b2cf6c1 fix(git-tools): pr-merge queue guard reads CI status from the BASE repo for fork PRs (B1) (#1334)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: code-infra-01 <[email protected]>
2026-08-20 20:18:59 +00:00
10 changed files with 424 additions and 26 deletions
+67
View File
@@ -81,6 +81,73 @@ pnpm format:check # Prettier check
pnpm build # Build all packages and applications
```
## Branch Model and Merge Process — `main` and `next` (CANONICAL)
**Every contribution targets `next` first. No exceptions.** Features, fixes, tests,
docs, and policy changes all take the same route; urgency changes queue priority,
never the route. Agents never commit to or merge into `main`.
| Branch | Role | Who merges into it |
| ------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `next` | Integration trunk — the only PR target for contributions | The designated merge-gate agent, after all gates pass. Never the PR author. |
| `main` | Stable/release line — receives promotion merges from `next` only | Jason only (or an agent he explicitly delegates for a named promotion). |
### Contribution sequencing (in order, no skipping)
1. **Issue first.** Work is tracked in a Gitea issue before a branch exists. The
issue number appears in the branch name and the PR body.
2. **Branch from the current `origin/next` head.** Name it
`feat/…`, `fix/…`, `docs/…`, or `test/…` with the issue number
(e.g. `docs/1214-branch-process`). Record the base SHA in the PR body.
3. **Develop with evidence.** Applicable tests accompany the change. Hooks are
never bypassed (`--no-verify` is prohibited). Stage explicit paths — never
`git add -A`.
4. **Open the PR against `next`.** The body states: scope, base SHA,
verification commands with results, and any known pre-existing failures on
the base — documented, not retried to green and not absorbed silently.
5. **CI must be terminal-green on the exact head.** All bounded Woodpecker
steps succeed (`verify-terminal-green` contract). Pipelines for fork PRs
start `blocked`; a maintainer approves the run — approving CI is not
approving the PR.
6. **Independent review. Self-merge is prohibited** — for every agent, on every
PR, including trivial ones. Where the change touches protected or
contract-bearing content, the reviewer verifies the exact head
(exact-byte/exact-blob comparison), not a description of it. An `AMEND`
verdict returns the PR to its author; the reviewer's gate stays held until
a fresh exact head passes.
7. **Merge into `next`** happens only after CI green + review pass, pinned to
the reviewed head SHA (a post-review push voids the review).
8. **Promotion `next` → `main`** is a deliberate, Jason-owned reconciliation
merge — not part of any contribution's lifecycle. Contributors are done at
step 7.
### Responsibilities
- **Contributor** — base pinning, green CI, evidence in the PR body,
responding to AMEND verdicts, never merging own work.
- **Reviewer / merge gate** — independent verification on the exact head;
holds and lifts gates; executes the merge into `next`.
- **Orchestrator / adjudicator** — cross-PR sequencing, disposition when PRs
collide, conflict adjudication.
- **Jason** — `next``main` promotions, merge-authority grants, collaborator
and token provisioning. Agents cannot grant themselves or each other any of
these.
### Hotfixes and divergence
- A hotfix follows the same path: branch from `next`, PR to `next`, gates,
merge, then an expedited Jason-owned promotion if `main` needs it urgently.
Committing the fix to `main` directly is prohibited even under pressure.
- **Never land work on `main` that is not on `next`.** This has happened
(issue #1152's goal controller reached `main` without reaching `next`) and
every later PR paid for it. If it happens anyway: transplant the work onto
a `next`-based branch with provenance-preserving commits
(`git cherry-pick -x` or explicit SHA references in the messages), PR it
through the normal gates, and let promotion re-align `main`. Do not
hand-patch `main` to compensate.
- Force-pushing a branch you do not own is prohibited; rebasing your own PR
branch is fine before review, and voids any review already given.
## Database and Local Runtime Safety
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
@@ -128,6 +128,7 @@ BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print(
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
@@ -144,10 +145,19 @@ if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
fi
if [[ "$DRY_RUN" != true ]]; then
# CI statuses for a PR live on the BASE repo (Woodpecker posts there),
# even when the head branch lives in a fork. Reading status against the
# fork repo yields statuses:null -> malformed for every fork PR (#1215,
# gate-merge-01 B1). The head repo is used only for head-sha identity;
# when metadata carries no base repository, the origin repo is where CI
# posts and remains correct for same-repo PRs.
if [[ -z "$BASE_REPO" ]]; then
BASE_REPO="$(get_repo_owner)/$(get_repo_name)"
fi
"$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \
-B "$HEAD_BRANCH" \
-R "$HEAD_REPO" \
-R "$BASE_REPO" \
--sha "$HEAD_SHA" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
@@ -209,6 +209,10 @@ base_ref = first_non_empty(
data.get('base_ref'),
data.get('base_label'),
)
base_repo = first_non_empty(
nested(data, 'base', 'repo', 'full_name'),
nested(data, 'base', 'repo', 'name_with_owner'),
)
if not head_ref or not base_ref:
available = ', '.join(sorted(data.keys()))
@@ -229,6 +233,7 @@ normalized = {
'headRefOid': head_sha,
'headRepository': head_repo,
'baseRefName': base_ref,
'baseRepository': base_repo,
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
'milestone': nested(data, 'milestone', 'title') or '',
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# B1 (stack #1215, gate-merge-01): for a fork PR the merge queue guard must
# read CI status against the BASE repository. Woodpecker posts statuses on the
# base repo; pr-metadata's headRepository names the fork, and passing it to
# ci-queue-wait yields statuses:null -> state=malformed rc=3 on every fork PR.
#
# This fixture omits baseRepository entirely (the pre-B1 normalizer's shape),
# so the guard must fall back to the origin repo — and must NEVER see the fork.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-fork-ci-status}"
FIXTURE_DIR="$WORK_DIR/tools/git"
CALL_LOG="$WORK_DIR/queue-call.log"
rm -rf "$WORK_DIR"
mkdir -p "$FIXTURE_DIR"
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"next","headRefName":"fix/b1-fork-branch","headRefOid":"fedcba9876543210fedcba9876543210fedcba98","headRepository":"stack-mos-dt-0/stack"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
exit 42
SH
chmod +x "$FIXTURE_DIR"/*.sh
# A git repo with an origin remote, so the origin fallback resolves.
git init -q "$WORK_DIR/upstream"
git -C "$WORK_DIR/upstream" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
set +e
(
cd "$WORK_DIR/upstream"
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
"$FIXTURE_DIR/pr-merge.sh" -n 1215
) >/dev/null 2>&1
rc=$?
set -e
if [[ "$rc" -ne 42 ]]; then
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
exit 1
fi
if [[ ! -s "$CALL_LOG" ]]; then
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
exit 1
fi
if grep -q -- '-R stack-mos-dt-0/stack' "$CALL_LOG"; then
echo "FAIL: queue guard received the FORK repository for CI status (B1 regression)" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '-R mosaicstack/stack' "$CALL_LOG"; then
echo "FAIL: queue guard did not receive the base (origin) repository" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '-B fix/b1-fork-branch' "$CALL_LOG"; then
echo "FAIL: queue guard did not receive the PR head branch" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '--sha fedcba9876543210fedcba9876543210fedcba98' "$CALL_LOG"; then
echo "FAIL: queue guard did not receive the exact PR head SHA" >&2
cat "$CALL_LOG" >&2
exit 1
fi
echo "pr-merge fork-PR CI-status repository regression passed"
@@ -15,7 +15,7 @@ cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
@@ -52,8 +52,13 @@ if grep -q -- '-B main' "$CALL_LOG"; then
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
if ! grep -q -- '-R mosaicstack/stack' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the BASE repository for CI status" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
echo "FAIL: merge queue guard received the fork head repository (B1: statuses are posted on the base repo)" >&2
cat "$CALL_LOG" >&2
exit 1
fi
@@ -126,7 +126,16 @@ if [ -z "$SRC_LABEL" ]; then
src_host=$(hostname -s 2>/dev/null || echo "?")
src_sess=${MOSAIC_AGENT_NAME:-}
if [ -z "$src_sess" ]; then
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
if [ -n "${TMUX:-}" ]; then
# Inside tmux: display-message resolves against this client's own session.
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
else
# Outside tmux with no name: display-message reports the LAST-ACTIVE
# session — someone else's identity (measured 2026-08-20: a nameless
# non-tmux sender was stamped "peggy", a live seat, forged silently).
# Stamp an explicit unverified label instead; deliberate senders use -S.
src_sess="unverified"
fi
fi
SRC_LABEL="${src_host}:${src_sess}"
fi
@@ -61,8 +61,15 @@ no() { FAIL=$((FAIL+1)); printf 'FAIL %s\n %s\n' "$1" "$2"; }
# Run the tool with the stub injected; echoes captured payload on stdout.
run() { AGENT_SEND_SENDER="$STUB" bash "$TOOL" -S a:src -n dsthost "$@"; }
run_auto() {
env -u MOSAIC_AGENT_NAME \
# Hermetic auto-label runs: TMUX is controlled explicitly so results never
# depend on whether the caller running this suite sits inside tmux.
run_auto() { # models a sender OUTSIDE tmux (no client context)
env -u MOSAIC_AGENT_NAME -u TMUX \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -n dsthost "$@"
}
run_auto_in_tmux() { # models a sender INSIDE tmux (client context exists)
env -u MOSAIC_AGENT_NAME TMUX=/fake/socket \
AGENT_SEND_SENDER="$STUB" PATH="$FAKE_BIN:$PATH" \
bash "$TOOL" -n dsthost "$@"
}
@@ -145,7 +152,9 @@ want="[$src_host:authoritative-agent -> dsthost:mos] env identity"
|| no "MOSAIC_AGENT_NAME is authoritative across sockets" "got=[$got] want=[$want]"
# 9. Without the env identity, self-lookup uses local tmux, not destination -L.
got=$(FAKE_TMUX_MODE=sessions run_auto -L destination-socket -s mos -m "local fallback")
# Sender is INSIDE tmux: the only context where display-message self-lookup
# is safe (it resolves against this client's own session).
got=$(FAKE_TMUX_MODE=sessions run_auto_in_tmux -L destination-socket -s mos -m "local fallback")
want="[$src_host:local-agent -> dsthost:mos] local fallback"
[ "$got" = "$want" ] && ok "cross-socket fallback uses local sender session" \
|| no "cross-socket fallback uses local sender session" "got=[$got] want=[$want]"
@@ -153,8 +162,17 @@ want="[$src_host:local-agent -> dsthost:mos] local fallback"
&& ok "cross-socket fallback rejects destination holder identity" \
|| no "cross-socket fallback rejects destination holder identity" "got=[$got]"
# 9b. NO tmux context: display-message answers with the LAST-ACTIVE session —
# someone else's identity (forgery vector). The label must be `unverified`,
# never a borrowed name, even though a tmux server exists here and the fake
# would confidently answer `local-agent`.
got=$(FAKE_TMUX_MODE=sessions run_auto -s mos -m "no tmux context")
want="[$src_host:unverified -> dsthost:mos] no tmux context"
[ "$got" = "$want" ] && ok "no-tmux sender labeled unverified, never borrowed" \
|| no "no-tmux sender labeled unverified, never borrowed" "got=[$got] want=[$want]"
# 10. If neither env nor local tmux identifies the sender, preserve '?'.
got=$(FAKE_TMUX_MODE=unavailable run_auto -L destination-socket -s mos -m "unknown fallback")
got=$(FAKE_TMUX_MODE=unavailable run_auto_in_tmux -L destination-socket -s mos -m "unknown fallback")
want="[$src_host:? -> dsthost:mos] unknown fallback"
[ "$got" = "$want" ] && ok "unknown sender falls back to ?" \
|| no "unknown sender falls back to ?" "got=[$got] want=[$want]"
+1 -1
View File
@@ -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 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.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/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_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/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_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-edit.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-no-status.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/_scripts/test-mosaic-init-rce.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 && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.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/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_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/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_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-edit.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-no-status.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-fork-ci-status.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/_scripts/test-mosaic-init-rce.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 && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -0,0 +1,152 @@
/**
* setupPath profile management (issue #1327, MOSAIC-IMPROVEMENTS 4c / D25).
*
* The profile append used to be guarded on the binDir value it was about to
* write, which is blind to accumulation across different Mosaic homes: every
* wizard run against a fresh temp home appended a permanent block to the
* operator's real shell profile (1,061 measured appends on sb-it-1-dt).
*
* Arms below map to the requirements:
* S1 sentinel-managed block, rewritten in place
* S2 a non-default target home never touches the operator profile
* S3 byte-identical profile across repeated runs
* S4 legacy unmarked `# Mosaic` blocks collapse into the managed block
* S5 the Windows ($env:Path) arm shares the same block logic
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
let profilePathMock: string | null = null;
vi.mock('../platform/detect.js', () => ({
getShellProfilePath: (): string | null => profilePathMock,
}));
import { setupPath, managedBlockFor, stripLegacyPathBlocks } from './finalize.js';
// The real resolved default on this host. Tests use it as the comparator a
// non-default home must fail against, exactly as the wizard would.
const REAL_DEFAULT_HOME = join(homedir(), '.config', 'mosaic');
function tempHome(prefix: string): string {
const dir = join(tmpdir(), prefix);
mkdirSync(join(dir, 'bin'), { recursive: true });
return dir;
}
describe('setupPath profile management (#1327)', () => {
let workDir: string;
let profileFile: string;
let defaultLikeHome: string;
let otherHome: string;
const baseline = '# existing operator content\nexport EDITOR=vim\n';
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'setuppath-spec-'));
profileFile = join(workDir, '.bashrc');
writeFileSync(profileFile, baseline, 'utf-8');
profilePathMock = profileFile;
defaultLikeHome = tempHome(join(workDir, 'home-a', '.config', 'mosaic'));
otherHome = tempHome(join(workDir, 'home-b', '.config', 'mosaic'));
});
afterEach(() => {
profilePathMock = null;
rmSync(workDir, { recursive: true, force: true });
});
// S2 — the arm that MUST fail against the pre-fix code: a home that is not
// the resolved default may not modify the operator profile at all.
it('does not touch the operator profile when the target home is not the resolved default', () => {
const action = setupPath(otherHome, REAL_DEFAULT_HOME);
expect(action).toBe('skipped');
expect(readFileSync(profileFile, 'utf-8')).toBe(baseline);
});
it('returns skipped when no shell profile can be resolved', () => {
profilePathMock = null;
const action = setupPath(defaultLikeHome, defaultLikeHome);
expect(action).toBe('skipped');
});
// S1 + S3 — two distinct homes (each run as the resolved default in turn,
// the shape of two legitimate installs against one operator profile) and
// repeated runs against the same home both leave exactly one block.
it('leaves exactly one managed block after runs against two distinct homes', () => {
const first = setupPath(defaultLikeHome, defaultLikeHome);
expect(first).toBe('added');
const second = setupPath(otherHome, otherHome);
expect(second).toBe('added');
const content = readFileSync(profileFile, 'utf-8');
const beginCount = content.split('# >>> mosaic begin >>>').length - 1;
const endCount = content.split('# <<< mosaic end <<<').length - 1;
expect(beginCount).toBe(1);
expect(endCount).toBe(1);
expect(content).toContain(join(otherHome, 'bin'));
expect(content).toContain(baseline);
});
it('is byte-identical across repeated runs against the same home', () => {
setupPath(defaultLikeHome, defaultLikeHome);
const afterFirst = readFileSync(profileFile, 'utf-8');
const again = setupPath(defaultLikeHome, defaultLikeHome);
expect(again).toBe('already');
expect(readFileSync(profileFile, 'utf-8')).toBe(afterFirst);
});
// S4 — pre-existing unmarked blocks from the old append logic collapse
// into the single managed block instead of accumulating beside it.
it('collapses legacy unmarked # Mosaic blocks into the managed block', () => {
const legacy =
'# existing operator content\n' +
'# Mosaic\n' +
'export PATH="/tmp/mosaic-dead-wizard-1/bin:$PATH"\n' +
'export EDITOR=vim\n' +
'# Mosaic\n' +
'export PATH="/tmp/mosaic-dead-wizard-2/bin:$PATH"\n';
writeFileSync(profileFile, legacy, 'utf-8');
const action = setupPath(defaultLikeHome, defaultLikeHome);
expect(action).toBe('added');
const content = readFileSync(profileFile, 'utf-8');
expect(content).not.toContain('/tmp/mosaic-dead-wizard-1/bin');
expect(content).not.toContain('/tmp/mosaic-dead-wizard-2/bin');
expect(content).toContain('export EDITOR=vim');
expect(content.split('# >>> mosaic begin >>>').length - 1).toBe(1);
expect(content).toContain(join(defaultLikeHome, 'bin'));
});
});
describe('managed block helpers (#1327)', () => {
// S5 — the Windows arm shares markers and shape with the POSIX arm.
it('builds the $env:Path variant inside the same markers', () => {
const block = managedBlockFor('C:\\Users\\op\\.config\\mosaic\\bin', true);
expect(block).toContain('# >>> mosaic begin >>>');
expect(block).toContain('# <<< mosaic end <<<');
expect(block).toContain('$env:Path = "C:\\Users\\op\\.config\\mosaic\\bin;$env:Path"');
});
it('builds the POSIX export variant inside the same markers', () => {
const block = managedBlockFor('/home/op/.config/mosaic/bin', false);
expect(block).toContain('# >>> mosaic begin >>>');
expect(block).toContain('export PATH="/home/op/.config/mosaic/bin:$PATH"');
expect(block).toContain('# <<< mosaic end <<<');
});
it('strips legacy $env:Path pairs on the Windows arm', () => {
const legacy =
'# Mosaic\n$env:Path = "C:\\tmp\\dead\\bin;$env:Path"\n' +
'# Mosaic\n$env:Path = "C:\\tmp\\dead2\\bin;$env:Path"\n' +
'Write-Host hi\n';
const stripped = stripLegacyPathBlocks(legacy, true);
expect(stripped).not.toContain('C:\\tmp\\dead');
expect(stripped).toContain('Write-Host hi');
});
});
+72 -16
View File
@@ -1,11 +1,12 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { platform } from 'node:os';
import type { WizardPrompter } from '../prompter/interface.js';
import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js';
import { getShellProfilePath } from '../platform/detect.js';
import { DEFAULT_MOSAIC_HOME } from '../constants.js';
import { ManifestError } from '../framework/manifest.js';
import {
getDefaultSkillPaths,
@@ -144,32 +145,87 @@ function runDoctor(mosaicHome: string): DoctorResult {
type PathAction = 'already' | 'added' | 'skipped';
function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
const binDir = join(mosaicHome, 'bin');
const currentPath = process.env['PATH'] ?? '';
const PATH_BLOCK_BEGIN = '# >>> mosaic begin >>>';
const PATH_BLOCK_END = '# <<< mosaic end <<<';
const PATH_BLOCK_NOTE = '# Managed by the Mosaic installer; this block is rewritten on install.';
if (currentPath.includes(binDir)) {
return 'already';
/**
* The managed PATH block written into the operator's shell profile.
*
* The block is delimited by begin/end sentinels so any number of installs,
* against any homes, collapse to exactly one block: the writer replaces the
* region between the sentinels instead of appending a second copy (#1327).
*/
export function managedBlockFor(binDir: string, isWindows: boolean): string {
const exportLine = isWindows
? `$env:Path = "${binDir};$env:Path"`
: `export PATH="${binDir}:$PATH"`;
return `${PATH_BLOCK_BEGIN}\n${PATH_BLOCK_NOTE}\n${exportLine}\n${PATH_BLOCK_END}\n`;
}
/**
* Remove legacy unmarked `# Mosaic` PATH pairs appended by pre-#1327
* installs. Only the exact two-line shape this installer used to write is
* removed; any other `# Mosaic` comment line is left alone.
*/
export function stripLegacyPathBlocks(content: string, isWindows: boolean): string {
const legacyExport = isWindows ? /^\$env:Path = ".*;\$env:Path"$/ : /^export PATH=".*:\$PATH"$/;
const lines = content.split('\n');
const kept: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? '';
const next = i + 1 < lines.length ? lines[i + 1] : undefined;
if (line === '# Mosaic' && next !== undefined && legacyExport.test(next)) {
i += 1;
continue;
}
kept.push(line);
}
return kept.join('\n');
}
/** Drop the region between the managed-block sentinels, first occurrence. */
function withoutManagedBlock(content: string): string {
const beginIdx = content.indexOf(PATH_BLOCK_BEGIN);
if (beginIdx < 0) return content;
const endIdx = content.indexOf(PATH_BLOCK_END, beginIdx);
if (endIdx < 0) return content;
return content.slice(0, beginIdx) + content.slice(endIdx + PATH_BLOCK_END.length);
}
export function setupPath(mosaicHome: string, resolvedDefaultHome: string): PathAction {
// Never write outside the home under test (#1327 S2): a wizard run against
// a non-default home (test harnesses, throwaway installs) must not mutate
// the operator's real shell profile.
if (mosaicHome !== resolvedDefaultHome) {
return 'skipped';
}
const binDir = join(mosaicHome, 'bin');
const profilePath = getShellProfilePath();
if (!profilePath) return 'skipped';
const isWindows = platform() === 'win32';
const exportLine = isWindows
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
const block = managedBlockFor(binDir, isWindows);
// Check if already in profile
let content = '';
if (existsSync(profilePath)) {
const content = readFileSync(profilePath, 'utf-8');
if (content.includes(binDir)) {
return 'already';
}
content = readFileSync(profilePath, 'utf-8');
}
// Migration (#1327 S4): legacy unmarked blocks collapse into the managed
// block, and an existing managed block is rewritten in place rather than
// appended beside itself (S1/S3).
const base = stripLegacyPathBlocks(withoutManagedBlock(content), isWindows);
const trimmed = base.replace(/\n+$/, '');
const next = trimmed.length === 0 ? block : `${trimmed}\n${block}`;
if (next === content) {
return 'already';
}
try {
appendFileSync(profilePath, exportLine, 'utf-8');
writeFileSync(profilePath, next, 'utf-8');
return 'added';
} catch {
return 'skipped';
@@ -286,7 +342,7 @@ export async function finalizeStage(
}
// 7. PATH setup
const pathAction = setupPath(state.mosaicHome, p);
const pathAction = setupPath(state.mosaicHome, DEFAULT_MOSAIC_HOME);
let summaryShown = false;
const showSummary = () => {