feat(mosaic): add constrained context recovery command
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: mosaic-context-refresh
|
||||
description: Run the constrained Mosaic context-recovery flow after compaction or directive-loss. This is a thin wrapper over the broker-backed recovery command; it never treats a receipt as a safety or residency proof.
|
||||
---
|
||||
|
||||
# mosaic-context-refresh
|
||||
|
||||
Use this only after compaction, session resume, or confirmed directive drift. It invokes the
|
||||
**single ungated mutator**, `tools/lease-broker/recover-context.py`; every other consequential
|
||||
mutator remains behind the verified lease gate.
|
||||
|
||||
## Wrapper procedure
|
||||
|
||||
1. The runtime supplies the exact validated normative-fragment construction and the current
|
||||
compaction/request epochs, then invokes:
|
||||
|
||||
```bash
|
||||
python3 "$MOSAIC_HOME/tools/lease-broker/recover-context.py" begin \
|
||||
--construction "$MOSAIC_CONTEXT_REFRESH_CONSTRUCTION" \
|
||||
--compaction-epoch "$MOSAIC_COMPACTION_EPOCH" \
|
||||
--request-epoch "$MOSAIC_REQUEST_EPOCH"
|
||||
```
|
||||
|
||||
The command delegates to the shipped WI-5 broker transition: revoke first, build the canonical
|
||||
`B_payload`/`H_payload`, enter `PENDING_DELIVERY`, and mint a fresh one-time challenge. It prints
|
||||
the terminal receipt envelope to deliver exactly as returned.
|
||||
|
||||
2. The current assistant message copies that one terminal receipt verbatim. It does not compute a
|
||||
hash, add prose, quote a prior receipt, or present a caller-supplied receipt/challenge.
|
||||
3. At `message_end`, the trusted runtime `ReceiptObserver` seam invokes:
|
||||
|
||||
```bash
|
||||
python3 "$MOSAIC_HOME/tools/lease-broker/recover-context.py" complete
|
||||
```
|
||||
|
||||
Completion supplies no receipt or challenge argument. The broker observes the exact latest
|
||||
assistant entry, commits evidence, consumes its own fresh challenge, and promotes VERIFIED last.
|
||||
If observation is absent, malformed, stale, or duplicated, recovery remains UNVERIFIED and a
|
||||
retry begins a new cycle.
|
||||
|
||||
## Scope and honesty
|
||||
|
||||
- A receipt from the normal verification path cannot be replayed through recovery: recovery mints a
|
||||
distinct current challenge and does not accept caller-provided receipt text as evidence.
|
||||
- Observable absent, malformed, prefix-truncated, and adapter-mutated terminal receipts do not
|
||||
promote. “Tail-only” is non-promoting only when the delivered terminal bytes are concretely
|
||||
malformed or incomplete.
|
||||
- **Negative capability:** a tail-preserving middle drop is not represented as receipt-detectable.
|
||||
It is a T-C injection-contract residual deferred to WI-7 server-side evidence; do not claim this
|
||||
skill or receipt catches it.
|
||||
- The receipt is a T-A delivery/liveness prerequisite only. It never proves obedience, comprehension,
|
||||
durable residency, or safety; the whole mutator-class gate and server-side branch protection retain
|
||||
those roles.
|
||||
|
||||
This source-resident skill is projected by the Mosaic skill bridge after framework install/upgrade.
|
||||
Do not create a live symlink manually.
|
||||
@@ -400,7 +400,7 @@ class Broker:
|
||||
response = self._handle(peer, request)
|
||||
if self.store.value != previous:
|
||||
self.store.commit()
|
||||
if request.get("action") == "promote_lease":
|
||||
if request.get("action") in {"promote_lease", "complete_recovery"}:
|
||||
session_id = request.get("session_id")
|
||||
if not isinstance(session_id, str):
|
||||
raise BrokerFailure("INVALID_IDENTITY")
|
||||
@@ -469,6 +469,71 @@ class Broker:
|
||||
raise BrokerFailure("TOKEN_REPLAY")
|
||||
del self.store.tokens()[token_value]
|
||||
return {"ok": True}
|
||||
if action == "begin_recovery":
|
||||
# Recovery is the one ungated mutator, but it is not a second
|
||||
# receipt protocol. It delegates to this exact normal-path
|
||||
# transition, then marks its volatile pending cycle so completion
|
||||
# can obtain the broker-minted challenge internally. Caller-supplied
|
||||
# receipt text is never an input to recovery.
|
||||
if any(field in request for field in ("receipt", "latest_assistant_message", "receipt_challenge")):
|
||||
raise BrokerFailure("INVALID_RECOVERY_REQUEST")
|
||||
normal_request = dict(request)
|
||||
normal_request["action"] = "begin_verification"
|
||||
response = self._handle(peer, normal_request)
|
||||
session_id = response.get("session_id")
|
||||
if not isinstance(session_id, str):
|
||||
# begin_verification deliberately does not return identity;
|
||||
# recover it only after its authenticated shared transition.
|
||||
candidate = request.get("session_id")
|
||||
if not isinstance(candidate, str):
|
||||
raise BrokerFailure("INVALID_IDENTITY")
|
||||
session_id = candidate
|
||||
lease = self.leases.get(session_id)
|
||||
if not isinstance(lease, dict) or lease.get("state") != LEASE_PENDING:
|
||||
raise BrokerFailure("STATE_INTEGRITY")
|
||||
lease["cycle_kind"] = "recovery"
|
||||
response["state"] = "PENDING_DELIVERY"
|
||||
return response
|
||||
if action == "complete_recovery":
|
||||
if any(field in request for field in ("receipt", "latest_assistant_message", "receipt_challenge")):
|
||||
raise BrokerFailure("INVALID_RECOVERY_REQUEST")
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
lease = self.leases.get(session_id)
|
||||
challenge = lease.get("receipt_challenge") if isinstance(lease, dict) else None
|
||||
if (
|
||||
not isinstance(lease, dict)
|
||||
or lease.get("cycle_kind") != "recovery"
|
||||
or lease.get("state") != LEASE_PENDING
|
||||
or not isinstance(challenge, str)
|
||||
):
|
||||
raise BrokerFailure("INVALID_LEASE_TRANSITION")
|
||||
try:
|
||||
# Reuse the shipped observe -> evidence commit -> consume ->
|
||||
# promote transition. The recovery caller supplies neither a
|
||||
# normal-path receipt nor a challenge; the trusted observer
|
||||
# and current broker cycle remain the sole evidence authority.
|
||||
observed_request = {
|
||||
"action": "observe_receipt",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": request["runtime_generation"],
|
||||
"receipt_challenge": challenge,
|
||||
}
|
||||
self._handle(peer, observed_request)
|
||||
return self._handle(peer, {
|
||||
"action": "promote_lease",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": request["runtime_generation"],
|
||||
"receipt_challenge": challenge,
|
||||
})
|
||||
except Exception:
|
||||
# A malformed, absent, or stale observed receipt must leave
|
||||
# no pending recovery capability or live lease. A retry mints
|
||||
# a new challenge through the shared begin transition.
|
||||
self.revoke_session_authority(session_id)
|
||||
self._rejected_cycle_fence = (
|
||||
copy.deepcopy(self.store.value), copy.deepcopy(self.leases)
|
||||
)
|
||||
raise
|
||||
if action == "begin_verification":
|
||||
session_id, _ = self.authenticate(peer_pid, request)
|
||||
runtime = request.get("runtime")
|
||||
|
||||
146
packages/mosaic/framework/tools/lease-broker/recover-context.py
Normal file
146
packages/mosaic/framework/tools/lease-broker/recover-context.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Constrained recovery command: the sole ungated Mosaic mutator.
|
||||
|
||||
This is deliberately a thin client of the broker's recovery entrypoint. It
|
||||
never accepts receipt text or a caller-provided challenge: the broker mints the
|
||||
fresh challenge, delivers its exact receipt envelope, and later asks the
|
||||
trusted ReceiptObserver seam to observe that same pending cycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from normative_fragments import build_payload_from_wire
|
||||
|
||||
MAX_FRAME: Final = 64 * 1024
|
||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||
|
||||
|
||||
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
||||
payload = (json.dumps(request, separators=(",", ":")) + "\n").encode()
|
||||
if len(payload) > MAX_FRAME:
|
||||
raise ValueError("recovery request too large")
|
||||
response = bytearray()
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(BROKER_TIMEOUT_SECONDS)
|
||||
connection.connect(str(socket_path))
|
||||
connection.sendall(payload)
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
while len(response) <= MAX_FRAME:
|
||||
chunk = connection.recv(min(4096, MAX_FRAME + 1 - len(response)))
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if len(response) > MAX_FRAME or not response.endswith(b"\n"):
|
||||
raise ValueError("invalid broker reply")
|
||||
value = json.loads(response)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("invalid broker reply")
|
||||
return value
|
||||
|
||||
|
||||
def load_construction(path: Path) -> dict[str, object]:
|
||||
raw = path.read_bytes()
|
||||
if len(raw) > MAX_FRAME:
|
||||
raise ValueError("construction exceeds broker frame limit")
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("construction must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def identity(environ: Mapping[str, str]) -> tuple[Path, str, int, str]:
|
||||
socket_path = Path(environ["MOSAIC_LEASE_BROKER_SOCKET"])
|
||||
session_id = environ["MOSAIC_LEASE_SESSION_ID"]
|
||||
generation = int(environ["MOSAIC_RUNTIME_GENERATION"])
|
||||
runtime = environ["MOSAIC_LEASE_RUNTIME"]
|
||||
if generation < 0 or runtime not in {"claude", "pi"}:
|
||||
raise ValueError("invalid runtime identity")
|
||||
return socket_path, session_id, generation, runtime
|
||||
|
||||
|
||||
def begin(
|
||||
construction_path: Path,
|
||||
compaction_epoch: int,
|
||||
request_epoch: int,
|
||||
environ: Mapping[str, str],
|
||||
) -> dict[str, object]:
|
||||
if compaction_epoch < 0 or request_epoch < 0:
|
||||
raise ValueError("epochs must be non-negative")
|
||||
construction = load_construction(construction_path)
|
||||
# Invoke the shared WI-5 construction before asking the broker to repeat
|
||||
# its authoritative admission/build. No digest or receipt enters via CLI.
|
||||
built = build_payload_from_wire(construction)
|
||||
if (
|
||||
built.injectionDecision != "ACCEPTED"
|
||||
or not built.promotion
|
||||
or not isinstance(built.h_source, str)
|
||||
or not isinstance(built.h_payload, str)
|
||||
):
|
||||
raise ValueError("payload construction refused")
|
||||
socket_path, session_id, generation, runtime = identity(environ)
|
||||
return broker_request(socket_path, {
|
||||
"action": "begin_recovery",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
"runtime": runtime,
|
||||
"binding": {
|
||||
"compaction_epoch": compaction_epoch,
|
||||
"request_epoch": request_epoch,
|
||||
"h_source": built.h_source,
|
||||
"h_payload": built.h_payload,
|
||||
"schema_version": 1,
|
||||
},
|
||||
"construction": construction,
|
||||
})
|
||||
|
||||
|
||||
def complete(environ: Mapping[str, str]) -> dict[str, object]:
|
||||
socket_path, session_id, generation, _runtime = identity(environ)
|
||||
# No receipt or challenge argument exists: recovery completion can only use
|
||||
# the broker's current recovery cycle and its trusted observer seam.
|
||||
return broker_request(socket_path, {
|
||||
"action": "complete_recovery",
|
||||
"session_id": session_id,
|
||||
"runtime_generation": generation,
|
||||
})
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subcommands = parser.add_subparsers(dest="phase", required=True)
|
||||
begin_parser = subcommands.add_parser("begin", help="mint and deliver a fresh recovery receipt")
|
||||
begin_parser.add_argument("--construction", required=True, type=Path)
|
||||
begin_parser.add_argument("--compaction-epoch", required=True, type=int)
|
||||
begin_parser.add_argument("--request-epoch", required=True, type=int)
|
||||
subcommands.add_parser("complete", help="observe and promote only the current recovery receipt")
|
||||
arguments = parser.parse_args(argv)
|
||||
source_environment = os.environ if environ is None else environ
|
||||
try:
|
||||
reply = (
|
||||
begin(
|
||||
arguments.construction,
|
||||
arguments.compaction_epoch,
|
||||
arguments.request_epoch,
|
||||
source_environment,
|
||||
)
|
||||
if arguments.phase == "begin"
|
||||
else complete(source_environment)
|
||||
)
|
||||
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"Mosaic constrained recovery refused: {error}", file=sys.stderr)
|
||||
return 2
|
||||
print(json.dumps(reply, separators=(",", ":")))
|
||||
return 0 if reply.get("ok") is True else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "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/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh"
|
||||
"test:framework-shell": "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/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readlinkSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
@@ -26,6 +27,9 @@ import {
|
||||
const LEGACY_SYNC_SCRIPT = fileURLToPath(
|
||||
new URL('../../framework/tools/_scripts/mosaic-sync-skills', import.meta.url),
|
||||
);
|
||||
const CONTEXT_REFRESH_SOURCE_SKILL = fileURLToPath(
|
||||
new URL('../../framework/skills/mosaic-context-refresh/SKILL.md', import.meta.url),
|
||||
);
|
||||
|
||||
describe('Claude skill bridge', () => {
|
||||
let root: string;
|
||||
@@ -360,6 +364,25 @@ describe('Claude skill bridge', () => {
|
||||
});
|
||||
|
||||
describe('syncClaudeSkills', () => {
|
||||
it('projects the durable context-refresh skill through the #824 bridge in a tmp root', () => {
|
||||
expect(existsSync(CONTEXT_REFRESH_SOURCE_SKILL)).toBe(true);
|
||||
const source = readFileSync(CONTEXT_REFRESH_SOURCE_SKILL, 'utf8');
|
||||
expect(source).toContain('recover-context.py');
|
||||
expect(source).toContain('middle drop is not represented as receipt-detectable');
|
||||
const skillDir = createSkill('mosaic-context-refresh');
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), source);
|
||||
|
||||
const result = syncClaudeSkills(paths);
|
||||
|
||||
expect(result).toEqual({
|
||||
registered: ['mosaic-context-refresh'],
|
||||
repaired: [],
|
||||
unchanged: [],
|
||||
conflicts: [],
|
||||
});
|
||||
expectCorrectLink('mosaic-context-refresh');
|
||||
});
|
||||
|
||||
it('generically creates every missing canonical link and repairs managed broken links', () => {
|
||||
createSkill('added-after-setup');
|
||||
createSkill('another-new-skill');
|
||||
|
||||
Reference in New Issue
Block a user