Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc6b593c1e | ||
|
|
9fed383884 | ||
|
|
6db0bead44 | ||
|
|
c671290d77 | ||
|
|
6a9b2cf6c1 |
@@ -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
|
||||
|
||||
@@ -54,6 +54,22 @@ def main(
|
||||
arguments = parser.parse_args(argv)
|
||||
source_environment = os.environ if environ is None else environ
|
||||
|
||||
# D29: a session that never held a lease has nothing to revoke, and that is a
|
||||
# SUCCESS, not a failed revocation. The block below is deliberately fail-closed
|
||||
# for a broker that is unreachable, which is right — but it cannot distinguish
|
||||
# "the broker is down" from "there was never a lease", so a bare-launched
|
||||
# session was denied every lifecycle transition, including compaction. Denying
|
||||
# compaction protects nothing there; it converts a recoverable context limit
|
||||
# into a lost session.
|
||||
#
|
||||
# Absence must be TOTAL to qualify. If exactly one variable is present the
|
||||
# session is half-provisioned, which is real misconfiguration, and it still
|
||||
# takes the fail-closed path below.
|
||||
lease_variables = ("MOSAIC_LEASE_BROKER_SOCKET", "MOSAIC_LEASE_SESSION_ID")
|
||||
present = [name for name in lease_variables if source_environment.get(name)]
|
||||
if not present:
|
||||
return 0
|
||||
|
||||
try:
|
||||
if not arguments.reason or len(arguments.reason) > 128:
|
||||
raise ValueError("invalid revoke reason")
|
||||
|
||||
@@ -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/lease-broker/revoke_noop_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,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""D29 contracts: no lease is a no-op success; half-provisioned still fails closed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
TOOLS = Path(__file__).parents[2] / "framework/tools/lease-broker"
|
||||
REVOKE_PATH = TOOLS / "revoke-lease.py"
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("revoke_lease", REVOKE_PATH)
|
||||
assert _spec and _spec.loader
|
||||
revoke_lease = importlib.util.module_from_spec(_spec)
|
||||
import sys as _sys
|
||||
|
||||
_sys.path.insert(0, str(TOOLS))
|
||||
_spec.loader.exec_module(revoke_lease)
|
||||
|
||||
ARGV = ["--runtime", "claude", "--reason", "pre-compact"]
|
||||
VALID_SESSION = "a" * 64
|
||||
|
||||
|
||||
def _explode(*_args, **_kwargs):
|
||||
raise AssertionError("broker must not be contacted when no lease is held")
|
||||
|
||||
|
||||
class RevokeWithoutLease(unittest.TestCase):
|
||||
def test_no_lease_variables_is_a_noop_success(self) -> None:
|
||||
"""The D29 case: bare-launched session, nothing to revoke, must not deny."""
|
||||
self.assertEqual(
|
||||
revoke_lease.main(ARGV, environ={}, request=_explode),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_no_lease_does_not_contact_the_broker(self) -> None:
|
||||
"""A no-op must be vacuous: no socket, no generation bump, no transport."""
|
||||
revoke_lease.main(ARGV, environ={"HOME": "/nonexistent"}, request=_explode)
|
||||
|
||||
def test_socket_without_session_still_fails_closed(self) -> None:
|
||||
"""Half-provisioned is misconfiguration, not absence. Fail-closed stands."""
|
||||
self.assertEqual(
|
||||
revoke_lease.main(
|
||||
ARGV,
|
||||
environ={"MOSAIC_LEASE_BROKER_SOCKET": "/tmp/nonexistent.sock"},
|
||||
request=_explode,
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
def test_session_without_socket_still_fails_closed(self) -> None:
|
||||
"""The mirror case, so the guard cannot be satisfied by either half alone."""
|
||||
self.assertEqual(
|
||||
revoke_lease.main(
|
||||
ARGV,
|
||||
environ={"MOSAIC_LEASE_SESSION_ID": VALID_SESSION},
|
||||
request=_explode,
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
def test_empty_string_counts_as_absent(self) -> None:
|
||||
"""An exported-but-empty variable is not a lease."""
|
||||
self.assertEqual(
|
||||
revoke_lease.main(
|
||||
ARGV,
|
||||
environ={
|
||||
"MOSAIC_LEASE_BROKER_SOCKET": "",
|
||||
"MOSAIC_LEASE_SESSION_ID": "",
|
||||
},
|
||||
request=_explode,
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -337,7 +337,13 @@ class ExecutableEntrypointTest(unittest.TestCase):
|
||||
runpy.run_path(str(TOOLS_DIR / "launch-runtime.py"), run_name="__main__")
|
||||
self.assertEqual(raised.exception.code, 64)
|
||||
|
||||
def test_revoker_entrypoint_denies_when_identity_environment_is_absent(self) -> None:
|
||||
def test_revoker_entrypoint_noops_when_identity_environment_is_absent(self) -> None:
|
||||
# D29 supersession. This assertion previously pinned rc=2. Absent identity
|
||||
# means no lease was ever held, so there is nothing to revoke and the correct
|
||||
# result is no-op success. The old pin was written in e4d7d45 (WI-3), the same
|
||||
# commit that shipped launch-runtime.py's lease-var provisioning, on the
|
||||
# assumption that an envless revoker was unreachable. D29 falsified that in
|
||||
# production. Behavioural pins live in src/lease-broker/revoke_noop_unittest.py.
|
||||
with patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
@@ -352,9 +358,42 @@ class ExecutableEntrypointTest(unittest.TestCase):
|
||||
io.StringIO()
|
||||
), self.assertRaises(SystemExit) as raised:
|
||||
runpy.run_path(str(TOOLS_DIR / "revoke-lease.py"), run_name="__main__")
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
self.assertEqual(raised.exception.code, 0)
|
||||
|
||||
def test_revoker_entrypoint_denies_when_identity_environment_is_half_provisioned(
|
||||
self,
|
||||
) -> None:
|
||||
# The no-op above is reachable ONLY when identity is TOTALLY absent. A
|
||||
# half-provisioned environment is a machinery-present failure and must still
|
||||
# fail closed. main() already pins this; the entrypoint did not, and the
|
||||
# entrypoint is what the runtime extension actually spawns.
|
||||
half_provisioned = (
|
||||
{"MOSAIC_LEASE_BROKER_SOCKET": "/run/test/broker.sock"},
|
||||
{"MOSAIC_LEASE_SESSION_ID": "d" * 64},
|
||||
)
|
||||
for environment in half_provisioned:
|
||||
with self.subTest(environment=environment), patch.object(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
str(TOOLS_DIR / "revoke-lease.py"),
|
||||
"--runtime",
|
||||
"claude",
|
||||
"--reason",
|
||||
"pre-compact",
|
||||
],
|
||||
), patch.dict(os.environ, environment, clear=True), redirect_stderr(
|
||||
io.StringIO()
|
||||
), self.assertRaises(SystemExit) as raised:
|
||||
runpy.run_path(str(TOOLS_DIR / "revoke-lease.py"), run_name="__main__")
|
||||
self.assertEqual(raised.exception.code, 2)
|
||||
|
||||
def test_gate_entrypoint_denies_when_identity_environment_is_absent(self) -> None:
|
||||
# Deliberately NOT changed alongside its revoker twin above. The asymmetry is
|
||||
# intentional: the gate's deny-on-absent is the authorization path and is
|
||||
# load-bearing, so absent identity must fail closed here. The revoker's rc=2
|
||||
# was inert in the same case (no session id means no broker call is possible),
|
||||
# which is why only the revoker moved under D29. Do not "restore symmetry".
|
||||
class Stdin:
|
||||
buffer = io.BytesIO(b'{"tool_name":"Bash"}')
|
||||
|
||||
@@ -703,8 +742,11 @@ class LeaseRevocationTest(unittest.TestCase):
|
||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||
}
|
||||
malformed_session = {**good, "MOSAIC_LEASE_SESSION_ID": "not-a-session"}
|
||||
# D29 exemption: the `({}, ...)` case was removed from this list. An empty
|
||||
# environment is absence-of-lease, not an identity/reply/transport failure, and
|
||||
# its correct result is no-op success (pinned in revoke_noop_unittest.py). The
|
||||
# five cases below are all machinery-present failures and stay fail-closed.
|
||||
cases = [
|
||||
({}, lambda *_args: {"ok": True, "state": "UNVERIFIED"}),
|
||||
(malformed_session, lambda *_args: {"ok": True, "state": "UNVERIFIED"}),
|
||||
(good, lambda *_args: {"ok": False, "state": "UNVERIFIED"}),
|
||||
(good, lambda *_args: {"ok": True, "state": "VERIFIED"}),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user