demo: commit the recording harness (#8)
This commit was merged in pull request #8.
This commit is contained in:
+254
@@ -0,0 +1,254 @@
|
||||
"""Left pane: drive zerto_rewind_mcp over real MCP stdio. Full rewind loop."""
|
||||
from __future__ import annotations
|
||||
import asyncio, json, os, shlex, subprocess, sys, time
|
||||
from pathlib import Path
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
SP = Path(__file__).parent
|
||||
CFG = json.loads((SP / "demo.json").read_text())
|
||||
REPO = "/home/justin/github/zerto-ai-rewind"
|
||||
|
||||
RST, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
|
||||
CYAN, GREEN, RED, YEL, MAG = "\033[36m", "\033[32m", "\033[31m", "\033[33m", "\033[35m"
|
||||
|
||||
SPEED = float(os.environ.get("DEMO_SPEED", "1.0"))
|
||||
|
||||
|
||||
|
||||
def recovered_bytes(rec: dict) -> bytes:
|
||||
"""zerto_recover_file returns the content now, not a path on the MCP host.
|
||||
|
||||
The server may be on another machine, so a path there is of no use here.
|
||||
"""
|
||||
import base64
|
||||
if rec.get("encoding") == "base64":
|
||||
return base64.b64decode(rec["content"])
|
||||
return rec["content"].encode()
|
||||
|
||||
def w(s=""):
|
||||
sys.stdout.write(s + "\n"); sys.stdout.flush()
|
||||
|
||||
|
||||
def type_out(s, delay=0.012, color=""):
|
||||
sys.stdout.write(color)
|
||||
for ch in s:
|
||||
sys.stdout.write(ch); sys.stdout.flush()
|
||||
time.sleep(delay * SPEED)
|
||||
sys.stdout.write(RST + "\n"); sys.stdout.flush()
|
||||
|
||||
|
||||
def beat(n=1.2):
|
||||
time.sleep(n * SPEED)
|
||||
|
||||
|
||||
STEP = [0]
|
||||
|
||||
|
||||
def step(title):
|
||||
STEP[0] += 1
|
||||
w()
|
||||
w(f"{BOLD}{CYAN}{'='*66}{RST}")
|
||||
type_out(f" STEP {STEP[0]} {title}", 0.008, BOLD + CYAN)
|
||||
w(f"{BOLD}{CYAN}{'='*66}{RST}")
|
||||
beat(0.5)
|
||||
|
||||
|
||||
def note(s):
|
||||
type_out(f" {s}", 0.010, DIM)
|
||||
|
||||
|
||||
def call_banner(tool, args):
|
||||
w(f"{MAG} -> MCP call{RST} {BOLD}{tool}{RST}")
|
||||
for k, v in args.items():
|
||||
w(f"{DIM} {k} = {v}{RST}")
|
||||
|
||||
|
||||
def show(payload, keep=None, limit=22):
|
||||
if keep:
|
||||
payload = {k: payload[k] for k in keep if k in payload}
|
||||
text = json.dumps(payload, indent=2, default=str)
|
||||
lines = text.splitlines()
|
||||
for line in lines[:limit]:
|
||||
w(f"{DIM} |{RST} {line}")
|
||||
if len(lines) > limit:
|
||||
w(f"{DIM} | ... {len(lines)-limit} more lines{RST}")
|
||||
|
||||
|
||||
def ssh_base():
|
||||
if CFG.get("password"):
|
||||
return ["sshpass", "-p", CFG["password"], "ssh", "-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR"]
|
||||
base = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR"]
|
||||
if CFG.get("key"):
|
||||
base += ["-i", CFG["key"]]
|
||||
return base
|
||||
|
||||
|
||||
def guest(cmd, show_cmd=True):
|
||||
target = f"{CFG['user']}@{CFG['host']}"
|
||||
full = ssh_base() + [target, cmd]
|
||||
if show_cmd:
|
||||
w(f"{YEL} $ ssh {target} {shlex.quote(cmd)}{RST}")
|
||||
r = subprocess.run(full, capture_output=True, text=True, timeout=60)
|
||||
out = (r.stdout + r.stderr).rstrip()
|
||||
for line in out.splitlines():
|
||||
w(f"{DIM} |{RST} {line}")
|
||||
return r.returncode, out
|
||||
|
||||
|
||||
async def main():
|
||||
w(f"\n{BOLD}{CYAN} ZERTO AI REWIND{RST} {DIM}live end-to-end{RST}\n")
|
||||
type_out(" An agent is about to change a file on a protected VM.", 0.014)
|
||||
type_out(" Zerto already journals that VM. Make the agent use it.", 0.014)
|
||||
beat(1.5)
|
||||
|
||||
params = StdioServerParameters(
|
||||
command=f"{REPO}/.venv/bin/zerto-rewind-mcp",
|
||||
env={**os.environ, "ZERTO_REWIND_CONFIG": f"{REPO}/config.json"},
|
||||
)
|
||||
devnull = open(os.devnull, 'w')
|
||||
async with stdio_client(params, errlog=devnull) as (r, s):
|
||||
async with ClientSession(r, s) as sess:
|
||||
step("Connect to the rewind MCP")
|
||||
await sess.initialize()
|
||||
tools = await sess.list_tools()
|
||||
note(f"{len(tools.tools)} tools from zerto_rewind_mcp")
|
||||
for t in tools.tools:
|
||||
w(f"{DIM} - {t.name}{RST}")
|
||||
beat(2.0)
|
||||
|
||||
async def _ticker(label):
|
||||
t0 = time.time()
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
el = int(time.time() - t0)
|
||||
sys.stdout.write(f"\r{DIM} {label} ... {el}s{RST}")
|
||||
sys.stdout.flush()
|
||||
except asyncio.CancelledError:
|
||||
el = int(time.time() - t0)
|
||||
if el >= 5:
|
||||
sys.stdout.write(f"\r{DIM} {label} ... {el}s done{RST}\n")
|
||||
else:
|
||||
sys.stdout.write("\r" + " " * 50 + "\r")
|
||||
sys.stdout.flush()
|
||||
raise
|
||||
|
||||
async def call(name, **args):
|
||||
call_banner(name, args)
|
||||
tick = asyncio.create_task(_ticker("working"))
|
||||
try:
|
||||
res = await sess.call_tool(name, args)
|
||||
finally:
|
||||
tick.cancel()
|
||||
try:
|
||||
await tick
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return json.loads(res.content[0].text)
|
||||
|
||||
# ---- 1. the good state on the guest
|
||||
step("The file the agent is about to break")
|
||||
guest(f"cat {CFG['guest_path']}")
|
||||
beat(1.8)
|
||||
|
||||
# ---- 2. find protection
|
||||
step("zerto_find_protection - is this VM protected?")
|
||||
note("Reads skip the guard. This is the read.")
|
||||
found = await call("zerto_find_protection", query=CFG["vm_query"])
|
||||
show(found)
|
||||
if not found.get("ok"):
|
||||
w(f"{RED} find_protection not ok - stopping{RST}"); return 1
|
||||
vm = found["vm"]
|
||||
beat(2.5)
|
||||
|
||||
# ---- 3. guard
|
||||
step("zerto_guard_before_mutate - pin the journal FIRST")
|
||||
note("Capture-before-execute. No tag, no change.")
|
||||
note("The checkpoint name records which agent, and what it is about to do.")
|
||||
note("Watch the journal pane on the right.")
|
||||
guard = await call("zerto_guard_before_mutate",
|
||||
query=CFG["vm_query"], change_id=CFG["change_id"], agent="claude",
|
||||
action=f"edit {CFG['guest_path']}")
|
||||
show(guard, keep=["ok", "tag", "tagged", "skipped", "errors", "message"])
|
||||
if not guard.get("ok"):
|
||||
w(f"{RED} GUARD FAILED - refusing the change{RST}"); return 1
|
||||
tag = guard["tag"]
|
||||
tagged = guard["tagged"][0]
|
||||
t_tag = time.strftime("%H:%M:%S")
|
||||
w(f"{GREEN}{BOLD} tagged checkpoint {tagged['checkpoint_id']} on {tagged['vpg_name']}{RST}")
|
||||
w(f"{GREEN} inserted at {t_tag}{RST}")
|
||||
w(f"{GREEN} name: {tag}{RST}")
|
||||
w(f"{DIM} this checkpoint is the rewind point. remember cp {tagged['checkpoint_id']}.{RST}")
|
||||
beat(4.0)
|
||||
|
||||
# ---- 4. the bad change
|
||||
step("Now the agent makes the bad change")
|
||||
guest(f"printf '%s\\n' {shlex.quote(CFG['bad_content'])} > {CFG['guest_path']}")
|
||||
guest(f"cat {CFG['guest_path']}")
|
||||
t_bad = time.strftime("%H:%M:%S")
|
||||
w(f"{RED}{BOLD} the good content is gone from the guest ({t_bad}){RST}")
|
||||
note("Git never had this file. Last night's backup is hours stale.")
|
||||
beat(2.5)
|
||||
|
||||
# ---- 5. refusal without a human
|
||||
step("zerto_recover_file without a human yes")
|
||||
deny = await call("zerto_recover_file",
|
||||
vpg_identifier=tagged["vpg_identifier"],
|
||||
vm_identifier=vm["vm_identifier"],
|
||||
checkpoint_identifier=tagged["checkpoint_id"],
|
||||
guest_path=CFG["guest_path"], confirmed=False)
|
||||
show(deny)
|
||||
w(f"{YEL} refused. FLR mounts a disk; a human says yes.{RST}")
|
||||
beat(2.5)
|
||||
|
||||
# ---- 6. recover
|
||||
rec_cp = tagged["checkpoint_id"]
|
||||
step("Human says yes - FLR from the pre-mutation checkpoint")
|
||||
w(f"{BOLD} provenance{RST}")
|
||||
w(f" {GREEN}cp {tagged['checkpoint_id']} inserted {t_tag}{RST} {DIM}<- guard, BEFORE the change{RST}")
|
||||
w(f" {RED}bad write {t_bad}{RST} {DIM}<- the mutation, AFTER{RST}")
|
||||
assert rec_cp == tagged["checkpoint_id"], "recovery must use the guard checkpoint"
|
||||
w(f"{DIM} recovering from cp {rec_cp}, not the newest checkpoint.{RST}")
|
||||
note(f"name: {tag}")
|
||||
rec = await call("zerto_recover_file",
|
||||
vpg_identifier=tagged["vpg_identifier"],
|
||||
vm_identifier=vm["vm_identifier"],
|
||||
checkpoint_identifier=tagged["checkpoint_id"],
|
||||
guest_path=CFG["guest_path"], confirmed=True)
|
||||
show(rec)
|
||||
if not rec.get("ok"):
|
||||
w(f"{RED} FLR failed{RST}"); return 1
|
||||
beat(1.5)
|
||||
w(f"{YEL} recovered {rec['bytes']} bytes, sha256 {rec['sha256'][:16]}...{RST}")
|
||||
body = recovered_bytes(rec).decode()
|
||||
for line in body.splitlines():
|
||||
w(f"{GREEN}{BOLD} | {line}{RST}")
|
||||
beat(2.5)
|
||||
|
||||
# ---- 7. put it back
|
||||
step("Copy it back to the guest")
|
||||
note("This part is scp, not Zerto. Zerto got the bytes back.")
|
||||
target = f"{CFG['user']}@{CFG['host']}"
|
||||
local_copy = SP / rec["name"]
|
||||
local_copy.write_bytes(recovered_bytes(rec))
|
||||
scp = (["sshpass", "-p", CFG["password"]] if CFG.get("password") else []) + \
|
||||
["scp", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR"] + (["-i", CFG["key"]] if CFG.get("key") else []) + \
|
||||
[str(local_copy), f"{target}:{CFG['guest_path']}"]
|
||||
w(f"{YEL} $ scp {rec['name']} {target}:{CFG['guest_path']}{RST}")
|
||||
subprocess.run(scp, capture_output=True, text=True, timeout=60)
|
||||
guest(f"cat {CFG['guest_path']}")
|
||||
beat(1.5)
|
||||
|
||||
w()
|
||||
w(f"{BOLD}{GREEN}{'='*66}{RST}")
|
||||
type_out(" REWOUND. RPO was the journal, not last night.", 0.016, BOLD + GREEN)
|
||||
w(f"{BOLD}{GREEN}{'='*66}{RST}")
|
||||
beat(4.0)
|
||||
return 0
|
||||
|
||||
sys.exit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user