fix(mosaic): wire constrained recovery runtime boundaries
This commit is contained in:
@@ -15,6 +15,9 @@ Each connection carries exactly one UTF-8 JSON object followed by one newline, c
|
|||||||
- `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token.
|
- `begin_verification`: authenticated identity, runtime (`claude` or `pi`), cycle `binding`, and a TTL no greater than 300 seconds. The broker revokes existing authority first, enters `PENDING_VERIFICATION`, and returns a single-use promotion token.
|
||||||
- `begin_recovery`: the constrained recovery entrypoint. It rejects caller-provided receipt/challenge fields and delegates to the same `begin_verification` transition, but reports `PENDING_DELIVERY` and marks the volatile cycle as recovery-owned.
|
- `begin_recovery`: the constrained recovery entrypoint. It rejects caller-provided receipt/challenge fields and delegates to the same `begin_verification` transition, but reports `PENDING_DELIVERY` and marks the volatile cycle as recovery-owned.
|
||||||
- `complete_recovery`: authenticated identity only. It rejects caller-provided receipt/challenge fields, obtains the current recovery challenge only from broker state, and delegates to the same trusted-observer → evidence → consume → promote sequence. An observation failure revokes recovery authority; retry starts a fresh challenge.
|
- `complete_recovery`: authenticated identity only. It rejects caller-provided receipt/challenge fields, obtains the current recovery challenge only from broker state, and delegates to the same trusted-observer → evidence → consume → promote sequence. An observation failure revokes recovery authority; retry starts a fresh challenge.
|
||||||
|
|
||||||
|
The daemon owns a second protected production observer socket (mode `0600`) unless a private `--test-observer-file` fixture is selected. That transport accepts only the exact `record_runtime_observation` schema after kernel `SO_PEERCRED` plus the existing anchor/ancestry authentication; it validates the pending runtime/generation before storing one finalized assistant entry for the in-process `RuntimeReceiptObserver`. It is **not** a broker request action. Claude sends its latest assistant entry from the Stop-hook transport; Pi sends only finalized `message_end` assistant content. The public broker socket continues to reject request-supplied `latest_assistant_message` in begin, observe, and complete paths.
|
||||||
|
|
||||||
- `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible.
|
- `promote_lease`: authenticated identity plus the exact pending promotion token. The broker commits token consumption before making `VERIFIED` visible.
|
||||||
- `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately. WI-3 Claude/Pi hooks send this existing action; `runtime` and bounded `reason` fields are diagnostic input only and never identity authority.
|
- `revoke_lease`: authenticated observer signal; deletes pending tokens and makes the session `UNVERIFIED` immediately. WI-3 Claude/Pi hooks send this existing action; `runtime` and bounded `reason` fields are diagnostic input only and never identity authority.
|
||||||
- `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease.
|
- `authorize_tool`: authenticated identity, runtime, and exact runtime-reported tool name. The broker returns an explicit allow/deny decision from the whole-class policy and current lease.
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""P6 constrained-recovery probe; BUILT ONLY and Mos-gated.
|
"""P6 constrained-recovery probe; BUILT ONLY and Mos-gated.
|
||||||
|
|
||||||
DO NOT self-fire. Under Mos authorization only:
|
DO NOT self-fire. Under Mos authorization only:
|
||||||
python3 -I -S -B docs/compaction-refresh/probes/p6_constrained_recovery.py
|
python3 -I -S -B docs/compaction-refresh/probes/p6_constrained_recovery.py
|
||||||
|
|
||||||
The default three isolated runs each start a fresh shipped daemon on a private
|
The default three isolated runs launch the shipped daemon plus its production
|
||||||
Unix socket and drive the shipped recovery *command* out of process. This
|
observer transport on private sockets. The driver invokes the shipped recovery
|
||||||
probe never resets broker state, replaces the observer seam, or reimplements
|
command and adapter gate identity; it never resets broker state, mocks promote,
|
||||||
promotion: it exercises `recover-context.py` and the daemon's real socket.
|
or taps a live model-output stream.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -31,8 +31,12 @@ HERE = Path(__file__).resolve().parent
|
|||||||
REPOSITORY = HERE.parents[2]
|
REPOSITORY = HERE.parents[2]
|
||||||
TOOLS = REPOSITORY / "packages/mosaic/framework/tools/lease-broker"
|
TOOLS = REPOSITORY / "packages/mosaic/framework/tools/lease-broker"
|
||||||
DAEMON = TOOLS / "daemon.py"
|
DAEMON = TOOLS / "daemon.py"
|
||||||
|
GATE = TOOLS / "mutator-gate.py"
|
||||||
RECOVERY_COMMAND = TOOLS / "recover-context.py"
|
RECOVERY_COMMAND = TOOLS / "recover-context.py"
|
||||||
|
OBSERVER_CLIENT = TOOLS / "receipt-observer-client.py"
|
||||||
FRAGMENTS = TOOLS / "normative_fragments.py"
|
FRAGMENTS = TOOLS / "normative_fragments.py"
|
||||||
|
CLAUDE_SETTINGS = REPOSITORY / "packages/mosaic/framework/runtime/claude/settings.json"
|
||||||
|
PI_EXTENSION = REPOSITORY / "packages/mosaic/framework/runtime/pi/mosaic-extension.ts"
|
||||||
|
|
||||||
|
|
||||||
def load_shipped_fragments():
|
def load_shipped_fragments():
|
||||||
@@ -76,35 +80,72 @@ def wait_ready(process: subprocess.Popen[str], socket_path: Path) -> None:
|
|||||||
raise TimeoutError("shipped daemon did not create private probe socket")
|
raise TimeoutError("shipped daemon did not create private probe socket")
|
||||||
|
|
||||||
|
|
||||||
def recovery_command(arguments: list[str], environment: dict[str, str]) -> dict[str, object]:
|
def run_json(command: list[str], environment: dict[str, str], input_value: object | None = None) -> dict[str, object]:
|
||||||
completed = subprocess.run(
|
completed = subprocess.run(
|
||||||
[sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), *arguments],
|
command,
|
||||||
|
input=None if input_value is None else json.dumps(input_value),
|
||||||
text=True,
|
text=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
env=environment,
|
env=environment,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
if not completed.stdout.endswith("\n"):
|
if not completed.stdout.endswith("\n"):
|
||||||
raise AssertionError(f"recovery command omitted framed result: {completed.stderr!r}")
|
raise AssertionError(f"command omitted framed result: {completed.stderr!r}")
|
||||||
reply = json.loads(completed.stdout)
|
reply = json.loads(completed.stdout)
|
||||||
if not isinstance(reply, dict):
|
if not isinstance(reply, dict):
|
||||||
raise AssertionError("recovery command result is not an object")
|
raise AssertionError("command result is not an object")
|
||||||
return reply
|
return reply
|
||||||
|
|
||||||
|
|
||||||
def run_once(index: int) -> None:
|
def gate_recovery(runtime: str, phase: str, environment: dict[str, str]) -> None:
|
||||||
# Parity guard: this is an out-of-process driver of the shipped recovery
|
command = [sys.executable, "-I", "-S", "-B", str(GATE), "--runtime", runtime]
|
||||||
# entrypoint, not a shadow receipt/promotion implementation.
|
if runtime == "claude":
|
||||||
source = RECOVERY_COMMAND.read_text(encoding="utf-8")
|
command.extend(["--recovery-command", str(RECOVERY_COMMAND)])
|
||||||
if '"action": "begin_recovery"' not in source or '"action": "complete_recovery"' not in source:
|
recovery_invocation = (
|
||||||
|
f"python3 {RECOVERY_COMMAND} begin --construction /tmp/p6.json "
|
||||||
|
"--compaction-epoch 1 --request-epoch 1"
|
||||||
|
if phase == "begin"
|
||||||
|
else f"python3 {RECOVERY_COMMAND} complete"
|
||||||
|
)
|
||||||
|
value = {"tool_name": "Bash", "tool_input": {"command": recovery_invocation}}
|
||||||
|
else:
|
||||||
|
value = {"tool_name": "mosaic_context_recover"}
|
||||||
|
completed = subprocess.run(command, input=json.dumps(value), text=True, capture_output=True, env=environment, check=False)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise AssertionError(f"{runtime} recovery invocation remained gated: {completed.stderr!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def record_production_observation(runtime: str, message: str, root: Path, environment: dict[str, str]) -> None:
|
||||||
|
command = [sys.executable, "-I", "-S", "-B", str(OBSERVER_CLIENT), "--runtime", runtime]
|
||||||
|
if runtime == "claude":
|
||||||
|
transcript = root / "claude-transcript.jsonl"
|
||||||
|
transcript.write_text(json.dumps({"message": {"role": "assistant", "content": message}}) + "\n", encoding="utf-8")
|
||||||
|
payload = {"transcript_path": str(transcript)}
|
||||||
|
command.append("--latest-entry")
|
||||||
|
else:
|
||||||
|
payload = {"latest_assistant_message": message}
|
||||||
|
completed = subprocess.run(command, input=json.dumps(payload), text=True, capture_output=True, env=environment, check=False)
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise AssertionError(f"{runtime} production observer transport refused: {completed.stderr!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_once(index: int, runtime: str) -> None:
|
||||||
|
# Parity guard: drive the shipped command and the repaired adapter/observer
|
||||||
|
# bytes, not a shadow receipt or promotion implementation.
|
||||||
|
recovery_source = RECOVERY_COMMAND.read_text(encoding="utf-8")
|
||||||
|
if '"action": "begin_recovery"' not in recovery_source or '"action": "complete_recovery"' not in recovery_source:
|
||||||
raise AssertionError("P6 parity guard: recovery command no longer drives shipped broker entrypoints")
|
raise AssertionError("P6 parity guard: recovery command no longer drives shipped broker entrypoints")
|
||||||
|
if "--recovery-command" not in CLAUDE_SETTINGS.read_text(encoding="utf-8"):
|
||||||
|
raise AssertionError("P6 parity guard: Claude recovery mapping is missing")
|
||||||
|
if "const RECOVERY_TOOL = 'mosaic_context_recover'" not in PI_EXTENSION.read_text(encoding="utf-8"):
|
||||||
|
raise AssertionError("P6 parity guard: Pi recovery tool mapping is missing")
|
||||||
|
|
||||||
fragments = load_shipped_fragments()
|
fragments = load_shipped_fragments()
|
||||||
root = Path(tempfile.mkdtemp(prefix=f"mosaic-p6-recovery-{index}-"))
|
root = Path(tempfile.mkdtemp(prefix=f"mosaic-p6-recovery-{index}-"))
|
||||||
os.chmod(root, 0o700)
|
os.chmod(root, 0o700)
|
||||||
socket_path = root / "broker.sock"
|
socket_path = root / "broker.sock"
|
||||||
|
observer_socket = root / "observer.sock"
|
||||||
state_path = root / "state.json"
|
state_path = root / "state.json"
|
||||||
observer_path = root / "test-observer.json"
|
|
||||||
construction_path = root / "construction.json"
|
construction_path = root / "construction.json"
|
||||||
content = b"P6 constrained recovery fixture\n"
|
content = b"P6 constrained recovery fixture\n"
|
||||||
construction = {
|
construction = {
|
||||||
@@ -120,7 +161,7 @@ def run_once(index: int) -> None:
|
|||||||
os.chmod(construction_path, 0o600)
|
os.chmod(construction_path, 0o600)
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
[sys.executable, "-I", "-S", "-B", str(DAEMON), "--socket", str(socket_path),
|
[sys.executable, "-I", "-S", "-B", str(DAEMON), "--socket", str(socket_path),
|
||||||
"--state", str(state_path), "--test-observer-file", str(observer_path)],
|
"--state", str(state_path), "--observer-socket", str(observer_socket)],
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
@@ -135,7 +176,7 @@ def run_once(index: int) -> None:
|
|||||||
built = fragments.build_payload_from_wire(construction)
|
built = fragments.build_payload_from_wire(construction)
|
||||||
normal = request(socket_path, {
|
normal = request(socket_path, {
|
||||||
"action": "begin_verification", "session_id": session_id, "runtime_generation": 1,
|
"action": "begin_verification", "session_id": session_id, "runtime_generation": 1,
|
||||||
"runtime": "pi", "construction": construction,
|
"runtime": runtime, "construction": construction,
|
||||||
"binding": {"compaction_epoch": index, "request_epoch": index + 100,
|
"binding": {"compaction_epoch": index, "request_epoch": index + 100,
|
||||||
"h_source": built.h_source, "h_payload": built.h_payload, "schema_version": 1},
|
"h_source": built.h_source, "h_payload": built.h_payload, "schema_version": 1},
|
||||||
})
|
})
|
||||||
@@ -146,13 +187,15 @@ def run_once(index: int) -> None:
|
|||||||
environment = {
|
environment = {
|
||||||
**os.environ,
|
**os.environ,
|
||||||
"MOSAIC_LEASE_BROKER_SOCKET": str(socket_path),
|
"MOSAIC_LEASE_BROKER_SOCKET": str(socket_path),
|
||||||
|
"MOSAIC_RECEIPT_OBSERVER_SOCKET": str(observer_socket),
|
||||||
"MOSAIC_LEASE_SESSION_ID": session_id,
|
"MOSAIC_LEASE_SESSION_ID": session_id,
|
||||||
"MOSAIC_RUNTIME_GENERATION": "1",
|
"MOSAIC_RUNTIME_GENERATION": "1",
|
||||||
"MOSAIC_LEASE_RUNTIME": "pi",
|
"MOSAIC_LEASE_RUNTIME": runtime,
|
||||||
}
|
}
|
||||||
recovery = recovery_command([
|
gate_recovery(runtime, "begin", environment)
|
||||||
"begin", "--construction", str(construction_path), "--compaction-epoch", str(index + 10),
|
recovery = run_json([
|
||||||
"--request-epoch", str(index + 110),
|
sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "begin", "--construction", str(construction_path),
|
||||||
|
"--compaction-epoch", str(index + 10), "--request-epoch", str(index + 110),
|
||||||
], environment)
|
], environment)
|
||||||
challenge = recovery.get("receipt_challenge")
|
challenge = recovery.get("receipt_challenge")
|
||||||
receipt = recovery.get("receipt")
|
receipt = recovery.get("receipt")
|
||||||
@@ -161,32 +204,26 @@ def run_once(index: int) -> None:
|
|||||||
if challenge == normal_challenge:
|
if challenge == normal_challenge:
|
||||||
raise AssertionError("recovery reused a normal-path challenge")
|
raise AssertionError("recovery reused a normal-path challenge")
|
||||||
|
|
||||||
# C4: a normal-path receipt is not an admissible recovery receipt.
|
# C4: production observer content is still exact-current-cycle only.
|
||||||
observer_path.write_text(json.dumps({
|
record_production_observation(runtime, normal_receipt, root, environment)
|
||||||
"session_id": session_id, "runtime_generation": 1, "latest_assistant_message": normal_receipt,
|
refused = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment)
|
||||||
}), encoding="utf-8")
|
|
||||||
os.chmod(observer_path, 0o600)
|
|
||||||
refused = recovery_command(["complete"], environment)
|
|
||||||
if refused.get("ok") is not False or refused.get("code") != "RECEIPT_MISMATCH":
|
if refused.get("ok") is not False or refused.get("code") != "RECEIPT_MISMATCH":
|
||||||
raise AssertionError(f"normal-path receipt replay was not refused: {refused!r}")
|
raise AssertionError(f"normal-path receipt replay was not refused: {refused!r}")
|
||||||
|
|
||||||
# Begin a new recovery cycle, then prove shipped evidence -> consume ->
|
gate_recovery(runtime, "begin", environment)
|
||||||
# promote ordering and post-consumption non-repromotion.
|
recovery = run_json([
|
||||||
recovery = recovery_command([
|
sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "begin", "--construction", str(construction_path),
|
||||||
"begin", "--construction", str(construction_path), "--compaction-epoch", str(index + 20),
|
"--compaction-epoch", str(index + 20), "--request-epoch", str(index + 120),
|
||||||
"--request-epoch", str(index + 120),
|
|
||||||
], environment)
|
], environment)
|
||||||
receipt = recovery.get("receipt")
|
receipt = recovery.get("receipt")
|
||||||
if recovery.get("state") != "PENDING_DELIVERY" or not isinstance(receipt, str):
|
if recovery.get("state") != "PENDING_DELIVERY" or not isinstance(receipt, str):
|
||||||
raise AssertionError(f"fresh recovery retry did not pend: {recovery!r}")
|
raise AssertionError(f"fresh recovery retry did not pend: {recovery!r}")
|
||||||
observer_path.write_text(json.dumps({
|
record_production_observation(runtime, receipt, root, environment)
|
||||||
"session_id": session_id, "runtime_generation": 1, "latest_assistant_message": receipt,
|
gate_recovery(runtime, "complete", environment)
|
||||||
}), encoding="utf-8")
|
promoted = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment)
|
||||||
os.chmod(observer_path, 0o600)
|
|
||||||
promoted = recovery_command(["complete"], environment)
|
|
||||||
if promoted.get("ok") is not True or promoted.get("state") != "VERIFIED":
|
if promoted.get("ok") is not True or promoted.get("state") != "VERIFIED":
|
||||||
raise AssertionError(f"recovery consume-before-promote failed: {promoted!r}")
|
raise AssertionError(f"recovery consume-before-promote failed: {promoted!r}")
|
||||||
replay = recovery_command(["complete"], environment)
|
replay = run_json([sys.executable, "-I", "-S", "-B", str(RECOVERY_COMMAND), "complete"], environment)
|
||||||
if replay.get("ok") is not False or replay.get("code") != "INVALID_LEASE_TRANSITION":
|
if replay.get("ok") is not False or replay.get("code") != "INVALID_LEASE_TRANSITION":
|
||||||
raise AssertionError(f"consumed recovery challenge re-promoted: {replay!r}")
|
raise AssertionError(f"consumed recovery challenge re-promoted: {replay!r}")
|
||||||
finally:
|
finally:
|
||||||
@@ -206,8 +243,8 @@ def main() -> None:
|
|||||||
arguments = parser.parse_args()
|
arguments = parser.parse_args()
|
||||||
if arguments.runs != 3:
|
if arguments.runs != 3:
|
||||||
raise SystemExit("P6 requires exactly three isolated runs")
|
raise SystemExit("P6 requires exactly three isolated runs")
|
||||||
for index in range(arguments.runs):
|
for index, runtime in enumerate(("pi", "claude", "pi")):
|
||||||
run_once(index)
|
run_once(index, runtime)
|
||||||
print("P6 constrained recovery probe PASS: 3 isolated shipped recovery-command runs")
|
print("P6 constrained recovery probe PASS: 3 isolated shipped recovery-command runs")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ The same check runs in the Mosaic package test suite and therefore in root CI. A
|
|||||||
|
|
||||||
Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected.
|
Clients must complete the request boundary before waiting for a reply. After sending the single JSON object and its terminating newline, the client **MUST half-close the socket's write side** (`shutdown(SHUT_WR)` in POSIX clients; `socket.end()` in Node) and only then await the response. Merely calling `write()` and waiting is invalid: the broker waits for EOF to enforce the exact-one-frame contract and fails closed at its one-second deadline. Do not replace `end()` with `write()` in client helpers. A delayed second frame remains malformed and is rejected.
|
||||||
|
|
||||||
`mosaic_context_recover` is the only unverified mutator class. Its durable `mosaic-context-refresh` skill is a thin wrapper over `tools/lease-broker/recover-context.py`: `begin` has the broker rebuild the validated `B_payload`/`H_payload`, revoke first, and mint a new `PENDING_DELIVERY` receipt challenge; `complete` accepts neither receipt text nor a challenge argument. It uses only the trusted `ReceiptObserver` seam for the exact latest assistant entry, commits evidence, consumes the broker-held challenge, and promotes VERIFIED last. A normal-path receipt cannot be replayed through recovery because each retry begins a distinct recovery cycle and recovery completion cannot receive caller-presented evidence.
|
`mosaic_context_recover` is the only unverified mutator class. Its durable `mosaic-context-refresh` skill is a thin wrapper over `tools/lease-broker/recover-context.py`: `begin` has the broker rebuild the validated `B_payload`/`H_payload`, revoke first, and mint a new `PENDING_DELIVERY` receipt challenge; `complete` accepts neither receipt text nor a challenge argument. Claude maps only the exact direct recovery executable/validated arguments to this exempt tool identity; ordinary `Bash` remains gated. Pi exposes only the `mosaic_context_recover` custom tool; ordinary `bash` and all other tools remain gated. A normal-path receipt cannot be replayed through recovery because each retry begins a distinct recovery cycle and recovery completion cannot receive caller-presented evidence.
|
||||||
|
|
||||||
|
Production daemon startup creates a separate private observer socket unless a test-only `--test-observer-file` fixture is selected. Claude's Stop hook sends its exact latest assistant entry and Pi's `message_end` handler sends only finalized assistant content to that authenticated transport; the broker public socket never accepts message text. This is byte-build and private out-of-process harness wiring only: do not activate it against a live daemon, live socket, systemd service, tmux session, or model-output stream outside the controlled integration procedure.
|
||||||
|
|
||||||
Receipt honesty is load-bearing: absent, malformed, prefix-truncated, and observable adapter-mutated terminal receipts do not promote. A tail-only case is non-promoting only where the concrete terminal payload is malformed or observably incomplete. A tail-preserving middle drop is **not receipt-detectable**; it is the disclosed T-C injection-contract residual deferred to WI-7 server-side evidence. The receipt remains a T-A delivery/liveness prerequisite, never a safety, obedience, or residency proof. The framework skill is source-resident and bridge-projected on install/upgrade; do not hand-create a live runtime symlink.
|
Receipt honesty is load-bearing: absent, malformed, prefix-truncated, and observable adapter-mutated terminal receipts do not promote. A tail-only case is non-promoting only where the concrete terminal payload is malformed or observably incomplete. A tail-preserving middle drop is **not receipt-detectable**; it is the disclosed T-C injection-contract residual deferred to WI-7 server-side evidence. The receipt remains a T-A delivery/liveness prerequisite, never a safety, obedience, or residency proof. The framework skill is source-resident and bridge-projected on install/upgrade; do not hand-create a live runtime symlink.
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# #833 constrained recovery command — build scratchpad
|
# #833 constrained recovery command — build scratchpad
|
||||||
|
|
||||||
- **Objective:** Deliver WI-6 only: the single ungated constrained recovery command, durable `mosaic-context-refresh` wrapper skill, AC-1 observable partial-delivery and C4 replay evidence, and an unfired P6 probe.
|
- **Objective:** Deliver WI-6 plus Mos-ruled B1/B2 repair only: authenticated narrow recovery invocation in Claude/Pi, production receipt-observer transport, AC-1/C4 preservation, and an unfired repaired P6 probe.
|
||||||
- **Authority:** STEP-0 SHA-256 verified 4/4 against the supplied BUILD-BRIEF, SPEC-v5, ratification, and red-team records.
|
- **Authority:** STEP-0 SHA-256 verified 4/4 against the supplied BUILD-BRIEF, SPEC-v5, ratification, and red-team records.
|
||||||
- **Base:** exact `07553ead337a70a9241f826d27571650262b289c`; new branch `feat/833-constrained-recovery-command`; merge-base assertion passed before any commit.
|
- **Base:** exact `07553ead337a70a9241f826d27571650262b289c`; new branch `feat/833-constrained-recovery-command`; merge-base assertion passed before any commit.
|
||||||
- **Constraints:** No rebase/pull during build; no live install/symlink or live broker/socket/tmux/systemd mutation; no self-review, PR, merge, push, or P6 fire. `docs/TASKS.md` is orchestrator-owned and will not be modified.
|
- **Constraints:** No rebase/pull during build; no live install/symlink or live broker/socket/tmux/systemd/model-stream activation; no self-review, PR, merge, push, or P6 fire. `docs/TASKS.md` is orchestrator-owned and will not be modified.
|
||||||
- **Plan:**
|
- **Plan:**
|
||||||
1. Add red-first unit tests against the recovery broker entrypoint for fresh recovery challenge, normal-receipt replay refusal, observable partial-delivery refusal, and the explicit middle-drop negative capability; commit the RED test and preserve its command output.
|
1. Add red-first unit tests against the recovery broker entrypoint for fresh recovery challenge, normal-receipt replay refusal, observable partial-delivery refusal, and the explicit middle-drop negative capability; commit the RED test and preserve its command output.
|
||||||
2. Implement the recovery command as a thin driver over shared WI-5 broker transitions and the trusted observer seam; it never accepts caller receipt text.
|
2. Implement the recovery command as a thin driver over shared WI-5 broker transitions and the trusted observer seam; it never accepts caller receipt text.
|
||||||
@@ -11,5 +11,5 @@
|
|||||||
4. Build an unfired, default-3-run P6 standalone real-socket driver; it is not added to package scripts and will not be run.
|
4. Build an unfired, default-3-run P6 standalone real-socket driver; it is not added to package scripts and will not be run.
|
||||||
5. Run targeted broker/mutator/receipt suites, lint, and format check; report head to `mosaic-100` and stop.
|
5. Run targeted broker/mutator/receipt suites, lint, and format check; report head to `mosaic-100` and stop.
|
||||||
- **Risks:** The observer can only represent an exact latest message. Tail-preserving middle-drop is intentionally not claimed receipt-detectable (T-C residual deferred to WI-7 server evidence).
|
- **Risks:** The observer can only represent an exact latest message. Tail-preserving middle-drop is intentionally not claimed receipt-detectable (T-C residual deferred to WI-7 server evidence).
|
||||||
- **Evidence:** RED test committed at `3b5513bd6efad0c06b599fc759a66cff4286db04`; expected `UNKNOWN_ACTION` is logged in `/home/hermes/agent-work/reviews/833-wi6-red.log`. GREEN: `pnpm --filter @mosaicstack/mosaic run test:framework-shell` passed (deadline 2, normative 5, receipt 5, recovery 4, runtime tools 25, launch guard 12, inventory 14/14); `git diff --check`; Python `py_compile`; and a tmp-only execution of the real #824 `syncClaudeSkills` bridge projected the source skill (`WI-6 #824 tmp projection PASS`). P6 was built but not fired. The ordinary package Vitest/lint/typecheck/root-format commands cannot resolve their executables in this intentionally dependency-free fresh worktree (`node_modules` absent); no install/symlink workaround was used.
|
- **Evidence:** Original RED test committed at `3b5513bd6efad0c06b599fc759a66cff4286db04`; expected `UNKNOWN_ACTION` is logged in `/home/hermes/agent-work/reviews/833-wi6-red.log`. Repair RED is committed at `f4beedc3e7ac2e142dcbdeefb0e5ee40c20d9b86` and logged in `/home/hermes/agent-work/reviews/833-wi6-repair-red.log` before B1/B2 source. The repair's private out-of-process harness proves exact Claude recovery mapping while UNVERIFIED, non-recovery Bash denial, Pi's exact custom recovery-tool registration, production Pi `message_end` and Claude latest-entry observer promotion, and S1 triple rejection. P6 was rebuilt but remains unfired. The ordinary package Vitest/lint/typecheck/root-format commands cannot resolve their executables in this intentionally dependency-free fresh worktree (`node_modules` absent); no install/symlink workaround was used.
|
||||||
- **Budget:** No explicit task token cap was supplied; scope is fixed to WI-6 and no unrelated behavior will be added.
|
- **Budget:** No explicit task token cap was supplied; scope is fixed to WI-6 and no unrelated behavior will be added.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude",
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
||||||
"timeout": 3
|
"timeout": 3
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -79,6 +79,11 @@
|
|||||||
"Stop": [
|
"Stop": [
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
|
||||||
|
"timeout": 3
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ import { registerLeaseLifecycleHooks, type LeaseLifecyclePiApi } from './lease-l
|
|||||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py');
|
const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py');
|
||||||
const LEASE_REVOKER = join(MOSAIC_HOME, 'tools', 'lease-broker', 'revoke-lease.py');
|
const LEASE_REVOKER = join(MOSAIC_HOME, 'tools', 'lease-broker', 'revoke-lease.py');
|
||||||
|
const RECOVERY_COMMAND = join(MOSAIC_HOME, 'tools', 'lease-broker', 'recover-context.py');
|
||||||
|
const RECEIPT_OBSERVER_CLIENT = join(
|
||||||
|
MOSAIC_HOME,
|
||||||
|
'tools',
|
||||||
|
'lease-broker',
|
||||||
|
'receipt-observer-client.py',
|
||||||
|
);
|
||||||
|
const RECOVERY_TOOL = 'mosaic_context_recover';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -135,6 +143,78 @@ function checkPiMutatorGate(toolName: string): { block: true; reason: string } |
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkPiRecoveryGate(): { block: true; reason: string } | undefined {
|
||||||
|
return checkPiMutatorGate(RECOVERY_TOOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMessageText(message: unknown): string | undefined {
|
||||||
|
if (typeof message !== 'object' || message === null) return undefined;
|
||||||
|
const value = message as { role?: unknown; content?: unknown };
|
||||||
|
if (value.role !== 'assistant') return undefined;
|
||||||
|
if (typeof value.content === 'string') return value.content;
|
||||||
|
if (!Array.isArray(value.content)) return undefined;
|
||||||
|
const text: string[] = [];
|
||||||
|
for (const part of value.content) {
|
||||||
|
if (typeof part !== 'object' || part === null) return undefined;
|
||||||
|
const typed = part as { type?: unknown; text?: unknown };
|
||||||
|
if (typed.type !== 'text' || typeof typed.text !== 'string') return undefined;
|
||||||
|
text.push(typed.text);
|
||||||
|
}
|
||||||
|
return text.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordPiMessageEnd(message: unknown): void {
|
||||||
|
const latestAssistantMessage = assistantMessageText(message);
|
||||||
|
if (latestAssistantMessage === undefined) return;
|
||||||
|
// This sends finalized Pi message_end content only to the daemon-owned
|
||||||
|
// authenticated observer transport, never to the public broker request API.
|
||||||
|
spawnSync('python3', [RECEIPT_OBSERVER_CLIENT, '--runtime', 'pi'], {
|
||||||
|
input: `${JSON.stringify({ latest_assistant_message: latestAssistantMessage })}\n`,
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 2_000,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPiRecoveryCommand(params: {
|
||||||
|
phase: 'begin' | 'complete';
|
||||||
|
construction?: string;
|
||||||
|
compactionEpoch?: number;
|
||||||
|
requestEpoch?: number;
|
||||||
|
}): { content: Array<{ type: 'text'; text: string }> } {
|
||||||
|
const args = [RECOVERY_COMMAND, params.phase];
|
||||||
|
if (params.phase === 'begin') {
|
||||||
|
if (
|
||||||
|
typeof params.construction !== 'string' ||
|
||||||
|
!Number.isInteger(params.compactionEpoch) ||
|
||||||
|
!Number.isInteger(params.requestEpoch) ||
|
||||||
|
params.compactionEpoch < 0 ||
|
||||||
|
params.requestEpoch < 0
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Recovery begin requires construction and non-negative epochs.' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
args.push(
|
||||||
|
'--construction',
|
||||||
|
params.construction,
|
||||||
|
'--compaction-epoch',
|
||||||
|
String(params.compactionEpoch),
|
||||||
|
'--request-epoch',
|
||||||
|
String(params.requestEpoch),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = spawnSync('python3', args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 3_000,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
const output = result.status === 0 ? String(result.stdout ?? '') : String(result.stderr ?? '');
|
||||||
|
return { content: [{ type: 'text', text: output || 'Constrained recovery refused.' }] };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mission detection
|
// Mission detection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -287,6 +367,32 @@ export default function register(pi: ExtensionAPI) {
|
|||||||
// class gate before execution. Broker/script failure blocks fail-closed.
|
// class gate before execution. Broker/script failure blocks fail-closed.
|
||||||
pi.on('tool_call', async (event) => checkPiMutatorGate(event.toolName));
|
pi.on('tool_call', async (event) => checkPiMutatorGate(event.toolName));
|
||||||
|
|
||||||
|
// Pi records only a finalized assistant entry at message_end. It never uses
|
||||||
|
// after_provider_response, which occurs before stream consumption.
|
||||||
|
pi.on('message_end', async (event) => {
|
||||||
|
recordPiMessageEnd((event as unknown as { message?: unknown }).message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The recovery custom tool is the only Pi invocation that maps to the
|
||||||
|
// broker's exempt RECOVERY_TOOL identity. It is not a Bash exception.
|
||||||
|
pi.registerTool({
|
||||||
|
name: RECOVERY_TOOL,
|
||||||
|
label: 'Mosaic Context Recovery',
|
||||||
|
description:
|
||||||
|
'Run the constrained broker-backed context recovery flow. This is the sole ungated mutator.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
phase: Type.Union([Type.Literal('begin'), Type.Literal('complete')]),
|
||||||
|
construction: Type.Optional(Type.String()),
|
||||||
|
compactionEpoch: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
|
requestEpoch: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params) {
|
||||||
|
const blocked = checkPiRecoveryGate();
|
||||||
|
if (blocked !== undefined) return { content: [{ type: 'text', text: blocked.reason }] };
|
||||||
|
return runPiRecoveryCommand(params);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ── Session Start ─────────────────────────────────────────────────────
|
// ── Session Start ─────────────────────────────────────────────────────
|
||||||
pi.on('session_start', async (_event, ctx) => {
|
pi.on('session_start', async (_event, ctx) => {
|
||||||
sessionCwd = process.cwd();
|
sessionCwd = process.cwd();
|
||||||
|
|||||||
@@ -12,31 +12,40 @@ mutator remains behind the verified lease gate.
|
|||||||
## Wrapper procedure
|
## Wrapper procedure
|
||||||
|
|
||||||
1. The runtime supplies the exact validated normative-fragment construction and the current
|
1. The runtime supplies the exact validated normative-fragment construction and the current
|
||||||
compaction/request epochs, then invokes:
|
compaction/request epochs.
|
||||||
|
- **Claude:** invoke only this direct command shape (no shell composition):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 "$MOSAIC_HOME/tools/lease-broker/recover-context.py" begin \
|
python3 "$MOSAIC_HOME/tools/lease-broker/recover-context.py" begin \
|
||||||
--construction "$MOSAIC_CONTEXT_REFRESH_CONSTRUCTION" \
|
--construction "$MOSAIC_CONTEXT_REFRESH_CONSTRUCTION" \
|
||||||
--compaction-epoch "$MOSAIC_COMPACTION_EPOCH" \
|
--compaction-epoch "$MOSAIC_COMPACTION_EPOCH" \
|
||||||
--request-epoch "$MOSAIC_REQUEST_EPOCH"
|
--request-epoch "$MOSAIC_REQUEST_EPOCH"
|
||||||
```
|
```
|
||||||
|
|
||||||
The command delegates to the shipped WI-5 broker transition: revoke first, build the canonical
|
Claude's all-tools gate maps this exact recovery executable and validated argument shape to
|
||||||
`B_payload`/`H_payload`, enter `PENDING_DELIVERY`, and mint a fresh one-time challenge. It prints
|
`mosaic_context_recover`; ordinary `Bash` remains gated.
|
||||||
|
|
||||||
|
- **Pi:** call the registered `mosaic_context_recover` tool with `phase: "begin"`,
|
||||||
|
`construction`, `compactionEpoch`, and `requestEpoch`. It is the exact broker-exempt tool name;
|
||||||
|
Pi `bash` and every other tool remain gated.
|
||||||
|
|
||||||
|
Both forms delegate 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. They print
|
||||||
the terminal receipt envelope to deliver exactly as returned.
|
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
|
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.
|
hash, add prose, quote a prior receipt, or present a caller-supplied receipt/challenge.
|
||||||
3. At `message_end`, the trusted runtime `ReceiptObserver` seam invokes:
|
3. The production trusted-observer transport records that finalized assistant entry before completion:
|
||||||
|
- **Claude** selects the latest assistant entry at its `Stop` hook.
|
||||||
|
- **Pi** records only finalized assistant content at `message_end` (never
|
||||||
|
`after_provider_response`).
|
||||||
|
|
||||||
```bash
|
Then invoke completion with the same adapter form: Claude runs
|
||||||
python3 "$MOSAIC_HOME/tools/lease-broker/recover-context.py" complete
|
`python3 "$MOSAIC_HOME/tools/lease-broker/recover-context.py" complete`; Pi calls
|
||||||
```
|
`mosaic_context_recover` with `phase: "complete"`. Completion supplies no receipt or challenge
|
||||||
|
argument. The broker observes the exact latest assistant entry, commits evidence, consumes its own
|
||||||
Completion supplies no receipt or challenge argument. The broker observes the exact latest
|
fresh challenge, and promotes VERIFIED last. If observation is absent, malformed, stale, or
|
||||||
assistant entry, commits evidence, consumes its own fresh challenge, and promotes VERIFIED last.
|
duplicated, recovery remains UNVERIFIED and a retry begins a new cycle.
|
||||||
If observation is absent, malformed, stale, or duplicated, recovery remains UNVERIFIED and a
|
|
||||||
retry begins a new cycle.
|
|
||||||
|
|
||||||
## Scope and honesty
|
## Scope and honesty
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import select
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import stat
|
import stat
|
||||||
@@ -28,7 +29,11 @@ if _MODULE_DIRECTORY not in sys.path:
|
|||||||
sys.path.insert(0, _MODULE_DIRECTORY)
|
sys.path.insert(0, _MODULE_DIRECTORY)
|
||||||
from normative_fragments import build_payload_from_wire
|
from normative_fragments import build_payload_from_wire
|
||||||
from receipt_challenge import is_verbatim_receipt, latest_assistant_digest, receipt_for
|
from receipt_challenge import is_verbatim_receipt, latest_assistant_digest, receipt_for
|
||||||
from receipt_observer import FileTestReceiptObserver, ReceiptObserver, UnavailableReceiptObserver
|
from receipt_observer import (
|
||||||
|
FileTestReceiptObserver,
|
||||||
|
ReceiptObserver,
|
||||||
|
RuntimeReceiptObserver,
|
||||||
|
)
|
||||||
|
|
||||||
MAX_FRAME: Final = 64 * 1024
|
MAX_FRAME: Final = 64 * 1024
|
||||||
MAX_STATE: Final = 4 * 1024 * 1024
|
MAX_STATE: Final = 4 * 1024 * 1024
|
||||||
@@ -302,7 +307,9 @@ class StateStore:
|
|||||||
class Broker:
|
class Broker:
|
||||||
def __init__(self, store: StateStore, observer: ReceiptObserver | None = None) -> None:
|
def __init__(self, store: StateStore, observer: ReceiptObserver | None = None) -> None:
|
||||||
self.store = store
|
self.store = store
|
||||||
self.observer: ReceiptObserver = observer if observer is not None else UnavailableReceiptObserver()
|
# Production construction always has a transport-capable observer. Test
|
||||||
|
# fixtures may inject their controlled observer explicitly.
|
||||||
|
self.observer: ReceiptObserver = observer if observer is not None else RuntimeReceiptObserver()
|
||||||
# Set only by begin_verification after its mandatory revoke-first fence.
|
# Set only by begin_verification after its mandatory revoke-first fence.
|
||||||
# It is preserved if later cycle admission is refused; all other broker
|
# It is preserved if later cycle admission is refused; all other broker
|
||||||
# actions retain the normal snapshot rollback behavior.
|
# actions retain the normal snapshot rollback behavior.
|
||||||
@@ -390,6 +397,40 @@ class Broker:
|
|||||||
raise BrokerFailure("STATE_INTEGRITY")
|
raise BrokerFailure("STATE_INTEGRITY")
|
||||||
lease["state"] = LEASE_VERIFIED
|
lease["state"] = LEASE_VERIFIED
|
||||||
|
|
||||||
|
def record_runtime_observation(
|
||||||
|
self, peer_pid: int, request: dict[str, object]
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Record only an authenticated adapter's finalized assistant message.
|
||||||
|
|
||||||
|
This method is deliberately unreachable through ``Broker.handle`` and
|
||||||
|
its public broker socket. The production observer socket calls it after
|
||||||
|
SO_PEERCRED/ancestry authentication, preserving the S1 rule that a
|
||||||
|
broker request can never carry ``latest_assistant_message``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
required = {
|
||||||
|
"action", "session_id", "runtime_generation", "runtime", "latest_assistant_message"
|
||||||
|
}
|
||||||
|
if set(request) != required or request.get("action") != "record_runtime_observation":
|
||||||
|
raise BrokerFailure("INVALID_OBSERVATION")
|
||||||
|
runtime = request.get("runtime")
|
||||||
|
message = request.get("latest_assistant_message")
|
||||||
|
if runtime not in READ_ONLY_TOOLS or not isinstance(message, str) or len(message.encode("utf-8")) > MAX_FRAME:
|
||||||
|
raise BrokerFailure("INVALID_OBSERVATION")
|
||||||
|
session_id, _ = self.authenticate(peer_pid, request)
|
||||||
|
generation = request["runtime_generation"]
|
||||||
|
lease = self.leases.get(session_id)
|
||||||
|
if (
|
||||||
|
not isinstance(lease, dict)
|
||||||
|
or lease.get("state") != LEASE_PENDING
|
||||||
|
or lease.get("runtime") != runtime
|
||||||
|
or lease.get("runtime_generation") != generation
|
||||||
|
or not isinstance(self.observer, RuntimeReceiptObserver)
|
||||||
|
):
|
||||||
|
raise BrokerFailure("OBSERVATION_UNAVAILABLE")
|
||||||
|
self.observer.record_latest_assistant_message(session_id, runtime, generation, message)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
def handle(self, peer: tuple[int, int, int], request: dict[str, object]) -> dict[str, object]:
|
||||||
if self.store.poisoned:
|
if self.store.poisoned:
|
||||||
raise StateCommitUncertain()
|
raise StateCommitUncertain()
|
||||||
@@ -783,14 +824,63 @@ def handle_connection(
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def handle_runtime_observation_connection(
|
||||||
|
connection: socket.socket,
|
||||||
|
broker: Broker,
|
||||||
|
broker_lock: threading.Lock,
|
||||||
|
) -> None:
|
||||||
|
"""Serve the authenticated production observer transport, never the broker API."""
|
||||||
|
|
||||||
|
with connection:
|
||||||
|
read_deadline = time.monotonic() + READ_DEADLINE_SECONDS
|
||||||
|
try:
|
||||||
|
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
|
||||||
|
peer = struct.unpack("3i", raw)
|
||||||
|
request = read_frame(connection, read_deadline)
|
||||||
|
except BrokerFailure as exc:
|
||||||
|
reply = {"ok": False, "code": exc.code}
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
acquired = broker_lock.acquire(timeout=HANDLE_QUEUE_TIMEOUT_SECONDS)
|
||||||
|
if not acquired:
|
||||||
|
reply = {"ok": False, "code": "BROKER_BUSY"}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
reply = broker.record_runtime_observation(peer[0], request)
|
||||||
|
except BrokerFailure as exc:
|
||||||
|
reply = {"ok": False, "code": exc.code}
|
||||||
|
finally:
|
||||||
|
broker_lock.release()
|
||||||
|
try:
|
||||||
|
connection.settimeout(SEND_TIMEOUT_SECONDS)
|
||||||
|
connection.sendall((json.dumps(reply, separators=(",", ":")) + "\n").encode())
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def serve(
|
def serve(
|
||||||
socket_path: Path,
|
socket_path: Path,
|
||||||
state_path: Path,
|
state_path: Path,
|
||||||
observer: ReceiptObserver | None = None,
|
observer: ReceiptObserver | None = None,
|
||||||
|
observer_socket_path: Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
secure_parent(socket_path)
|
secure_parent(socket_path)
|
||||||
if socket_path.exists() or socket_path.is_symlink():
|
if socket_path.exists() or socket_path.is_symlink():
|
||||||
raise BrokerFailure("SOCKET_ALREADY_EXISTS")
|
raise BrokerFailure("SOCKET_ALREADY_EXISTS")
|
||||||
|
runtime_observer = observer is None
|
||||||
|
if runtime_observer:
|
||||||
|
observer = RuntimeReceiptObserver()
|
||||||
|
observer_socket_path = observer_socket_path or socket_path.with_name("receipt-observer.sock")
|
||||||
|
if observer_socket_path == socket_path:
|
||||||
|
raise BrokerFailure("OBSERVER_SOCKET_CONFLICT")
|
||||||
|
secure_parent(observer_socket_path)
|
||||||
|
if observer_socket_path.exists() or observer_socket_path.is_symlink():
|
||||||
|
raise BrokerFailure("OBSERVER_SOCKET_ALREADY_EXISTS")
|
||||||
|
elif observer_socket_path is not None:
|
||||||
|
raise BrokerFailure("TEST_OBSERVER_SOCKET_CONFLICT")
|
||||||
|
|
||||||
store = StateStore(state_path)
|
store = StateStore(state_path)
|
||||||
broker = Broker(store, observer)
|
broker = Broker(store, observer)
|
||||||
broker_lock = threading.Lock()
|
broker_lock = threading.Lock()
|
||||||
@@ -805,16 +895,28 @@ def serve(
|
|||||||
server.bind(str(socket_path))
|
server.bind(str(socket_path))
|
||||||
os.chmod(socket_path, 0o600)
|
os.chmod(socket_path, 0o600)
|
||||||
owned = (socket_path.stat().st_dev, socket_path.stat().st_ino)
|
owned = (socket_path.stat().st_dev, socket_path.stat().st_ino)
|
||||||
|
observer_server: socket.socket | None = None
|
||||||
|
observer_owned: tuple[int, int] | None = None
|
||||||
|
if runtime_observer and observer_socket_path is not None:
|
||||||
|
observer_server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
observer_server.bind(str(observer_socket_path))
|
||||||
|
os.chmod(observer_socket_path, 0o600)
|
||||||
|
observer_owned = (observer_socket_path.stat().st_dev, observer_socket_path.stat().st_ino)
|
||||||
stopping = False
|
stopping = False
|
||||||
|
|
||||||
def stop(_signum: int, _frame: object) -> None:
|
def stop(_signum: int, _frame: object) -> None:
|
||||||
nonlocal stopping
|
nonlocal stopping
|
||||||
stopping = True
|
stopping = True
|
||||||
server.close()
|
server.close()
|
||||||
|
if observer_server is not None:
|
||||||
|
observer_server.close()
|
||||||
|
|
||||||
def process_connection(connection: socket.socket) -> None:
|
def process_connection(connection: socket.socket, is_observer: bool) -> None:
|
||||||
try:
|
try:
|
||||||
handle_connection(connection, broker, broker_lock)
|
if is_observer:
|
||||||
|
handle_runtime_observation_connection(connection, broker, broker_lock)
|
||||||
|
else:
|
||||||
|
handle_connection(connection, broker, broker_lock)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
with fatal_lock:
|
with fatal_lock:
|
||||||
if not fatal_errors:
|
if not fatal_errors:
|
||||||
@@ -829,57 +931,73 @@ def serve(
|
|||||||
signal.signal(signal.SIGTERM, stop)
|
signal.signal(signal.SIGTERM, stop)
|
||||||
signal.signal(signal.SIGINT, stop)
|
signal.signal(signal.SIGINT, stop)
|
||||||
server.listen(MAX_IN_FLIGHT_CONNECTIONS)
|
server.listen(MAX_IN_FLIGHT_CONNECTIONS)
|
||||||
server.settimeout(0.1)
|
server.setblocking(False)
|
||||||
|
if observer_server is not None:
|
||||||
|
observer_server.listen(MAX_IN_FLIGHT_CONNECTIONS)
|
||||||
|
observer_server.setblocking(False)
|
||||||
print("READY", flush=True)
|
print("READY", flush=True)
|
||||||
try:
|
try:
|
||||||
while not stopping:
|
while not stopping:
|
||||||
failure = fatal_error()
|
failure = fatal_error()
|
||||||
if failure is not None:
|
if failure is not None:
|
||||||
raise failure
|
raise failure
|
||||||
if not slots.acquire(timeout=0.1):
|
listeners = [server, *([observer_server] if observer_server is not None else [])]
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
connection, _ = server.accept()
|
ready, _, _ = select.select(listeners, [], [], 0.1)
|
||||||
except socket.timeout:
|
except (OSError, ValueError):
|
||||||
slots.release()
|
|
||||||
continue
|
|
||||||
except OSError:
|
|
||||||
slots.release()
|
|
||||||
failure = fatal_error()
|
|
||||||
if failure is not None:
|
|
||||||
raise failure
|
|
||||||
if stopping:
|
if stopping:
|
||||||
break
|
break
|
||||||
raise
|
raise
|
||||||
try:
|
for listener in ready:
|
||||||
executor.submit(process_connection, connection)
|
if not slots.acquire(blocking=False):
|
||||||
except Exception:
|
continue
|
||||||
slots.release()
|
try:
|
||||||
connection.close()
|
connection, _ = listener.accept()
|
||||||
raise
|
except BlockingIOError:
|
||||||
|
slots.release()
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
slots.release()
|
||||||
|
if stopping:
|
||||||
|
break
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
executor.submit(process_connection, connection, listener is observer_server)
|
||||||
|
except Exception:
|
||||||
|
slots.release()
|
||||||
|
connection.close()
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
server.close()
|
server.close()
|
||||||
|
if observer_server is not None:
|
||||||
|
observer_server.close()
|
||||||
executor.shutdown(wait=True)
|
executor.shutdown(wait=True)
|
||||||
try:
|
for path, inode in ((socket_path, owned), (observer_socket_path, observer_owned)):
|
||||||
current = socket_path.stat()
|
if path is None or inode is None:
|
||||||
if (current.st_dev, current.st_ino) == owned:
|
continue
|
||||||
socket_path.unlink()
|
try:
|
||||||
except FileNotFoundError:
|
current = path.stat()
|
||||||
pass
|
if (current.st_dev, current.st_ino) == inode:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--socket", required=True, type=Path)
|
parser.add_argument("--socket", required=True, type=Path)
|
||||||
parser.add_argument("--state", required=True, type=Path)
|
parser.add_argument("--state", required=True, type=Path)
|
||||||
|
parser.add_argument("--observer-socket", type=Path)
|
||||||
parser.add_argument("--test-observer-file", type=Path)
|
parser.add_argument("--test-observer-file", type=Path)
|
||||||
arguments = parser.parse_args()
|
arguments = parser.parse_args()
|
||||||
|
if arguments.observer_socket is not None and arguments.test_observer_file is not None:
|
||||||
|
raise BrokerFailure("TEST_OBSERVER_SOCKET_CONFLICT")
|
||||||
observer = (
|
observer = (
|
||||||
FileTestReceiptObserver(arguments.test_observer_file)
|
FileTestReceiptObserver(arguments.test_observer_file)
|
||||||
if arguments.test_observer_file is not None
|
if arguments.test_observer_file is not None
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
serve(arguments.socket, arguments.state, observer)
|
serve(arguments.socket, arguments.state, observer, arguments.observer_socket)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ def main(
|
|||||||
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
environment["MOSAIC_RUNTIME_GENERATION"] = str(generation)
|
||||||
environment["MOSAIC_LEASE_GENERATION_FILE"] = str(generation_file)
|
environment["MOSAIC_LEASE_GENERATION_FILE"] = str(generation_file)
|
||||||
environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime
|
environment["MOSAIC_LEASE_RUNTIME"] = arguments.runtime
|
||||||
|
# Matches daemon.py's production default; deployments using a distinct
|
||||||
|
# observer socket may set this authenticated transport path explicitly.
|
||||||
|
environment.setdefault(
|
||||||
|
"MOSAIC_RECEIPT_OBSERVER_SOCKET",
|
||||||
|
str(socket_path.with_name("receipt-observer.sock")),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
execute(command[0], command, environment)
|
execute(command[0], command, environment)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
@@ -8,14 +8,21 @@ import json
|
|||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import shlex
|
||||||
from collections.abc import Callable, Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import BinaryIO, Final
|
from typing import BinaryIO, Final
|
||||||
|
|
||||||
|
# Isolated (`python -I`) adapter invocations must still import co-located
|
||||||
|
# framework modules; never depend on the caller's PYTHONPATH.
|
||||||
|
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
||||||
|
if _MODULE_DIRECTORY not in sys.path:
|
||||||
|
sys.path.insert(0, _MODULE_DIRECTORY)
|
||||||
from lease_generation import read_runtime_generation
|
from lease_generation import read_runtime_generation
|
||||||
|
|
||||||
MAX_FRAME: Final = 64 * 1024
|
MAX_FRAME: Final = 64 * 1024
|
||||||
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||||
|
RECOVERY_TOOL: Final = "mosaic_context_recover"
|
||||||
|
|
||||||
|
|
||||||
def deny(code: str) -> int:
|
def deny(code: str) -> int:
|
||||||
@@ -23,7 +30,7 @@ def deny(code: str) -> int:
|
|||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
|
||||||
def read_tool_name(stream: BinaryIO | None = None) -> str:
|
def read_tool_request(stream: BinaryIO | None = None) -> dict[str, object]:
|
||||||
source = sys.stdin.buffer if stream is None else stream
|
source = sys.stdin.buffer if stream is None else stream
|
||||||
raw = source.read(MAX_FRAME + 1)
|
raw = source.read(MAX_FRAME + 1)
|
||||||
if len(raw) > MAX_FRAME:
|
if len(raw) > MAX_FRAME:
|
||||||
@@ -34,7 +41,53 @@ def read_tool_name(stream: BinaryIO | None = None) -> str:
|
|||||||
tool_name = value.get("tool_name")
|
tool_name = value.get("tool_name")
|
||||||
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256:
|
if not isinstance(tool_name, str) or not tool_name or len(tool_name) > 256:
|
||||||
raise ValueError("INVALID_GATE_INPUT")
|
raise ValueError("INVALID_GATE_INPUT")
|
||||||
return tool_name
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def read_tool_name(stream: BinaryIO | None = None) -> str:
|
||||||
|
"""Backward-compatible strict extraction for callers that need only the name."""
|
||||||
|
|
||||||
|
return str(read_tool_request(stream)["tool_name"])
|
||||||
|
|
||||||
|
|
||||||
|
def recovery_invocation_name(request: dict[str, object], recovery_command: Path | None) -> str:
|
||||||
|
"""Map only a direct Claude Bash invocation of the recovery executable.
|
||||||
|
|
||||||
|
The mapping is adapter-configured and remains broker-authenticated through
|
||||||
|
the ordinary authorization request. It never blesses Bash generally, shell
|
||||||
|
composition, a different executable, or unsupported recovery arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tool_name = request["tool_name"]
|
||||||
|
if tool_name != "Bash" or recovery_command is None:
|
||||||
|
return str(tool_name)
|
||||||
|
tool_input = request.get("tool_input")
|
||||||
|
if not isinstance(tool_input, dict) or set(tool_input) != {"command"}:
|
||||||
|
return str(tool_name)
|
||||||
|
command = tool_input.get("command")
|
||||||
|
if not isinstance(command, str) or not command:
|
||||||
|
return str(tool_name)
|
||||||
|
try:
|
||||||
|
argv = shlex.split(command, posix=True)
|
||||||
|
except ValueError:
|
||||||
|
return str(tool_name)
|
||||||
|
if len(argv) < 3 or argv[0] != "python3":
|
||||||
|
return str(tool_name)
|
||||||
|
try:
|
||||||
|
if Path(argv[1]).resolve(strict=False) != recovery_command.resolve(strict=False):
|
||||||
|
return str(tool_name)
|
||||||
|
except OSError:
|
||||||
|
return str(tool_name)
|
||||||
|
phase = argv[2]
|
||||||
|
if phase == "complete" and len(argv) == 3:
|
||||||
|
return RECOVERY_TOOL
|
||||||
|
if phase != "begin" or len(argv) != 9:
|
||||||
|
return str(tool_name)
|
||||||
|
expected_flags = {"--construction", "--compaction-epoch", "--request-epoch"}
|
||||||
|
supplied_flags = set(argv[3::2])
|
||||||
|
if supplied_flags != expected_flags or any(not value for value in argv[4::2]):
|
||||||
|
return str(tool_name)
|
||||||
|
return RECOVERY_TOOL
|
||||||
|
|
||||||
|
|
||||||
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
def broker_request(socket_path: Path, request: dict[str, object]) -> dict[str, object]:
|
||||||
@@ -70,11 +123,13 @@ def main(
|
|||||||
) -> int:
|
) -> int:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
||||||
|
parser.add_argument("--recovery-command", type=Path)
|
||||||
arguments = parser.parse_args(argv)
|
arguments = parser.parse_args(argv)
|
||||||
source_environment = os.environ if environ is None else environ
|
source_environment = os.environ if environ is None else environ
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tool_name = read_tool_name(stream)
|
request_input = read_tool_request(stream)
|
||||||
|
tool_name = recovery_invocation_name(request_input, arguments.recovery_command)
|
||||||
socket_value = source_environment["MOSAIC_LEASE_BROKER_SOCKET"]
|
socket_value = source_environment["MOSAIC_LEASE_BROKER_SOCKET"]
|
||||||
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
|
session_id = source_environment["MOSAIC_LEASE_SESSION_ID"]
|
||||||
generation = resolve_generation(source_environment)
|
generation = resolve_generation(source_environment)
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Authenticated adapter-to-daemon transport for finalized assistant receipts.
|
||||||
|
|
||||||
|
This is not a broker request client. It writes only to the daemon-owned observer
|
||||||
|
socket, which authenticates SO_PEERCRED/ancestry before retaining a message for
|
||||||
|
the broker's ReceiptObserver seam.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
|
||||||
|
MAX_FRAME: Final = 64 * 1024
|
||||||
|
BROKER_TIMEOUT_SECONDS: Final = 1.5
|
||||||
|
MAX_TRANSCRIPT_BYTES: Final = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(stream: object) -> dict[str, object]:
|
||||||
|
raw = getattr(stream, "buffer", stream).read(MAX_FRAME + 1)
|
||||||
|
if not isinstance(raw, bytes) or len(raw) > MAX_FRAME:
|
||||||
|
raise ValueError("invalid observer input")
|
||||||
|
value = json.loads(raw)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("invalid observer input")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def assistant_text(entry: object) -> str | None:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
message = entry.get("message", entry)
|
||||||
|
if not isinstance(message, dict) or message.get("role") != "assistant":
|
||||||
|
return None
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return None
|
||||||
|
parts: list[str] = []
|
||||||
|
for item in content:
|
||||||
|
if not isinstance(item, dict) or item.get("type") != "text" or not isinstance(item.get("text"), str):
|
||||||
|
return None
|
||||||
|
parts.append(item["text"])
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def claude_latest_entry(value: dict[str, object]) -> str:
|
||||||
|
transcript_path = value.get("transcript_path")
|
||||||
|
if not isinstance(transcript_path, str) or not transcript_path:
|
||||||
|
raise ValueError("invalid Claude observer input")
|
||||||
|
path = Path(transcript_path)
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||||
|
descriptor = os.open(path, flags)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(descriptor)
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_TRANSCRIPT_BYTES:
|
||||||
|
raise ValueError("unsafe Claude transcript")
|
||||||
|
raw = os.read(descriptor, MAX_TRANSCRIPT_BYTES + 1)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
if len(raw) > MAX_TRANSCRIPT_BYTES:
|
||||||
|
raise ValueError("oversized Claude transcript")
|
||||||
|
for line in reversed(raw.decode("utf-8").splitlines()):
|
||||||
|
try:
|
||||||
|
text = assistant_text(json.loads(line))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError("invalid Claude transcript") from exc
|
||||||
|
if text is not None:
|
||||||
|
return text
|
||||||
|
raise ValueError("Claude transcript has no assistant entry")
|
||||||
|
|
||||||
|
|
||||||
|
def pi_message_end(value: dict[str, object]) -> str:
|
||||||
|
if set(value) != {"latest_assistant_message"} or not isinstance(value["latest_assistant_message"], str):
|
||||||
|
raise ValueError("invalid Pi observer input")
|
||||||
|
return value["latest_assistant_message"]
|
||||||
|
|
||||||
|
|
||||||
|
def observer_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("observer 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 observer reply")
|
||||||
|
value = json.loads(response)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("invalid observer reply")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None, *, environ: Mapping[str, str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--runtime", required=True, choices=("claude", "pi"))
|
||||||
|
parser.add_argument("--latest-entry", action="store_true")
|
||||||
|
arguments = parser.parse_args(argv)
|
||||||
|
source_environment = os.environ if environ is None else environ
|
||||||
|
try:
|
||||||
|
source = read_json(sys.stdin)
|
||||||
|
if arguments.runtime == "claude":
|
||||||
|
if not arguments.latest_entry:
|
||||||
|
raise ValueError("Claude observer requires --latest-entry")
|
||||||
|
message = claude_latest_entry(source)
|
||||||
|
else:
|
||||||
|
if arguments.latest_entry:
|
||||||
|
raise ValueError("Pi observer is message_end only")
|
||||||
|
message = pi_message_end(source)
|
||||||
|
if len(message.encode("utf-8")) > MAX_FRAME:
|
||||||
|
raise ValueError("assistant message too large")
|
||||||
|
reply = observer_request(Path(source_environment["MOSAIC_RECEIPT_OBSERVER_SOCKET"]), {
|
||||||
|
"action": "record_runtime_observation",
|
||||||
|
"session_id": source_environment["MOSAIC_LEASE_SESSION_ID"],
|
||||||
|
"runtime_generation": int(source_environment["MOSAIC_RUNTIME_GENERATION"]),
|
||||||
|
"runtime": arguments.runtime,
|
||||||
|
"latest_assistant_message": message,
|
||||||
|
})
|
||||||
|
except (KeyError, OSError, ValueError, json.JSONDecodeError) as error:
|
||||||
|
print(f"Mosaic receipt observer refused: {error}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
return 0 if reply == {"ok": True} else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Trusted latest-assistant-message observer boundary for receipt promotion.
|
"""Trusted latest-assistant-message observer boundary for receipt promotion.
|
||||||
|
|
||||||
Runtime adapters must implement ``observe_latest_assistant_message`` directly:
|
Production adapters deliver finalized assistant content over the daemon-owned
|
||||||
Claude selects the exact latest assistant entry and Pi selects ``message_end``.
|
observer socket after the daemon authenticates their peer against the broker's
|
||||||
The broker accepts no observed message through its request protocol.
|
kernel-anchored session identity. The broker request protocol never accepts
|
||||||
|
assistant-message content. Claude supplies its latest assistant entry; Pi
|
||||||
|
supplies finalized assistant content at ``message_end``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -26,7 +28,7 @@ class ReceiptObserver(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class UnavailableReceiptObserver:
|
class UnavailableReceiptObserver:
|
||||||
"""Production-safe default until a runtime adapter injects an observer."""
|
"""Fail-closed only for direct unit construction without daemon transport."""
|
||||||
|
|
||||||
def observe_latest_assistant_message(
|
def observe_latest_assistant_message(
|
||||||
self,
|
self,
|
||||||
@@ -38,6 +40,31 @@ class UnavailableReceiptObserver:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeReceiptObserver:
|
||||||
|
"""Daemon-owned production observer populated only by authenticated adapters."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._messages: dict[tuple[str, str, int], str] = {}
|
||||||
|
|
||||||
|
def record_latest_assistant_message(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
runtime: str,
|
||||||
|
runtime_generation: int,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
self._messages[(session_id, runtime, runtime_generation)] = message
|
||||||
|
|
||||||
|
def observe_latest_assistant_message(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
runtime: str,
|
||||||
|
runtime_generation: int,
|
||||||
|
_binding: dict[str, object],
|
||||||
|
) -> str | None:
|
||||||
|
return self._messages.get((session_id, runtime, runtime_generation))
|
||||||
|
|
||||||
|
|
||||||
class TestReceiptObserver:
|
class TestReceiptObserver:
|
||||||
"""Deterministic controlled observer used only by byte-build tests."""
|
"""Deterministic controlled observer used only by byte-build tests."""
|
||||||
|
|
||||||
@@ -60,7 +87,7 @@ class TestReceiptObserver:
|
|||||||
|
|
||||||
|
|
||||||
class FileTestReceiptObserver:
|
class FileTestReceiptObserver:
|
||||||
"""Private fixture-file observer for isolated out-of-process test drivers."""
|
"""Private fixture-file observer for isolated out-of-process test drivers only."""
|
||||||
|
|
||||||
def __init__(self, path: Path) -> None:
|
def __init__(self, path: Path) -> None:
|
||||||
self.path = path
|
self.path = path
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ from collections.abc import Mapping, Sequence
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
|
# The out-of-process recovery command is intentionally runnable with `python
|
||||||
|
# -I`; locate its shipped construction module without caller-controlled paths.
|
||||||
|
_MODULE_DIRECTORY = str(Path(__file__).resolve().parent)
|
||||||
|
if _MODULE_DIRECTORY not in sys.path:
|
||||||
|
sys.path.insert(0, _MODULE_DIRECTORY)
|
||||||
from normative_fragments import build_payload_from_wire
|
from normative_fragments import build_payload_from_wire
|
||||||
|
|
||||||
MAX_FRAME: Final = 64 * 1024
|
MAX_FRAME: Final = 64 * 1024
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ class RuntimeBoundaryFixture(unittest.TestCase):
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
child.kill()
|
child.kill()
|
||||||
child.wait()
|
child.wait()
|
||||||
|
if child.stdout is not None:
|
||||||
|
child.stdout.close()
|
||||||
self.temporary.cleanup()
|
self.temporary.cleanup()
|
||||||
|
|
||||||
def start_daemon(self, *, production_observer: bool) -> None:
|
def start_daemon(self, *, production_observer: bool) -> None:
|
||||||
@@ -145,6 +147,23 @@ class RecoveryRuntimeBoundaryTest(RuntimeBoundaryFixture):
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
self.assertEqual(mapped.returncode, 0, mapped.stderr)
|
self.assertEqual(mapped.returncode, 0, mapped.stderr)
|
||||||
|
mapped_begin = subprocess.run(
|
||||||
|
[sys.executable, "-I", "-S", "-B", str(GATE), "--runtime", "claude", "--recovery-command", str(RECOVERY)],
|
||||||
|
input=json.dumps({
|
||||||
|
"tool_name": "Bash",
|
||||||
|
"tool_input": {
|
||||||
|
"command": (
|
||||||
|
f"python3 {RECOVERY} begin --construction /tmp/construction.json "
|
||||||
|
"--compaction-epoch 1 --request-epoch 1"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
env={**environment, "MOSAIC_LEASE_RUNTIME": "claude"},
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(mapped_begin.returncode, 0, mapped_begin.stderr)
|
||||||
ordinary_bash = subprocess.run(
|
ordinary_bash = subprocess.run(
|
||||||
[sys.executable, "-I", "-S", "-B", str(GATE), "--runtime", "claude", "--recovery-command", str(RECOVERY)],
|
[sys.executable, "-I", "-S", "-B", str(GATE), "--runtime", "claude", "--recovery-command", str(RECOVERY)],
|
||||||
input=json.dumps({"tool_name": "Bash", "tool_input": {"command": "echo not-recovery"}}),
|
input=json.dumps({"tool_name": "Bash", "tool_input": {"command": "echo not-recovery"}}),
|
||||||
@@ -180,14 +199,28 @@ class RecoveryRuntimeBoundaryTest(RuntimeBoundaryFixture):
|
|||||||
|
|
||||||
# S1: production transport is not a broker request field. The public
|
# S1: production transport is not a broker request field. The public
|
||||||
# broker endpoint keeps rejecting caller-supplied assistant evidence.
|
# broker endpoint keeps rejecting caller-supplied assistant evidence.
|
||||||
rejected = request(self.socket, {
|
rejected_begin = request(self.socket, {
|
||||||
|
"action": "begin_recovery",
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": 1,
|
||||||
|
"receipt": receipt,
|
||||||
|
})
|
||||||
|
self.assertEqual(rejected_begin, {"ok": False, "code": "INVALID_RECOVERY_REQUEST"})
|
||||||
|
rejected_observe = request(self.socket, {
|
||||||
"action": "observe_receipt",
|
"action": "observe_receipt",
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"runtime_generation": 1,
|
"runtime_generation": 1,
|
||||||
"receipt_challenge": cycle["receipt_challenge"],
|
"receipt_challenge": cycle["receipt_challenge"],
|
||||||
"latest_assistant_message": receipt,
|
"latest_assistant_message": receipt,
|
||||||
})
|
})
|
||||||
self.assertEqual(rejected, {"ok": False, "code": "INVALID_RECEIPT"})
|
self.assertEqual(rejected_observe, {"ok": False, "code": "INVALID_RECEIPT"})
|
||||||
|
rejected_complete = request(self.socket, {
|
||||||
|
"action": "complete_recovery",
|
||||||
|
"session_id": session_id,
|
||||||
|
"runtime_generation": 1,
|
||||||
|
"latest_assistant_message": receipt,
|
||||||
|
})
|
||||||
|
self.assertEqual(rejected_complete, {"ok": False, "code": "INVALID_RECOVERY_REQUEST"})
|
||||||
|
|
||||||
recorded = subprocess.run(
|
recorded = subprocess.run(
|
||||||
[sys.executable, "-I", "-S", "-B", str(OBSERVER_CLIENT), "--runtime", "pi"],
|
[sys.executable, "-I", "-S", "-B", str(OBSERVER_CLIENT), "--runtime", "pi"],
|
||||||
@@ -208,6 +241,42 @@ class RecoveryRuntimeBoundaryTest(RuntimeBoundaryFixture):
|
|||||||
self.assertEqual(complete.returncode, 0, complete.stderr)
|
self.assertEqual(complete.returncode, 0, complete.stderr)
|
||||||
self.assertEqual(json.loads(complete.stdout).get("state"), "VERIFIED")
|
self.assertEqual(json.loads(complete.stdout).get("state"), "VERIFIED")
|
||||||
|
|
||||||
|
# The independent Claude transport selects one latest assistant entry
|
||||||
|
# from its hook transcript; it is not a Pi/message_end fallback.
|
||||||
|
claude_environment = {**environment, "MOSAIC_LEASE_RUNTIME": "claude"}
|
||||||
|
claude_begin = subprocess.run(
|
||||||
|
[sys.executable, "-I", "-S", "-B", str(RECOVERY), "begin", "--construction", str(self.construction()), "--compaction-epoch", "2", "--request-epoch", "2"],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
env=claude_environment,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(claude_begin.returncode, 0, claude_begin.stderr)
|
||||||
|
claude_receipt = json.loads(claude_begin.stdout)["receipt"]
|
||||||
|
transcript = self.root / "claude-transcript.jsonl"
|
||||||
|
transcript.write_text(
|
||||||
|
json.dumps({"message": {"role": "assistant", "content": claude_receipt}}) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
claude_recorded = subprocess.run(
|
||||||
|
[sys.executable, "-I", "-S", "-B", str(OBSERVER_CLIENT), "--runtime", "claude", "--latest-entry"],
|
||||||
|
input=json.dumps({"transcript_path": str(transcript)}),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
env=claude_environment,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(claude_recorded.returncode, 0, claude_recorded.stderr)
|
||||||
|
claude_complete = subprocess.run(
|
||||||
|
[sys.executable, "-I", "-S", "-B", str(RECOVERY), "complete"],
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
env=claude_environment,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(claude_complete.returncode, 0, claude_complete.stderr)
|
||||||
|
self.assertEqual(json.loads(claude_complete.stdout).get("state"), "VERIFIED")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user