Skip to content

Commit 863db6a

Browse files
author
Shadowfetch
committed
Preserve mission input edits and publish verified release artifacts safely
1 parent 3059442 commit 863db6a

13 files changed

Lines changed: 660 additions & 57 deletions

File tree

Makefile

Lines changed: 16 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,11 @@ PACKAGES_STAMP := $(BUILD_DIR)/.packages-$(VERSION)
7474

7575
R2_BUCKET ?= shadowfetch-linux
7676
# Cloudflare R2 S3-compatible endpoint for this account.
77-
# (wrangler can't PUT > 300 MiB; aws-cli with R2's S3 API handles multipart.)
77+
# The release publisher uses the S3 API with multipart uploads.
7878
R2_ENDPOINT ?= https://<CLOUDFLARE_ACCOUNT_ID>.r2.cloudflarestorage.com
7979
R2_REGION ?= auto
8080

81-
# Used by sync-from-linux (Mac-side flow): host + path to the build box.
81+
# Used by sync-from-linux for read-only artifact inspection.
8282
LINUX_HOST ?= shadowfetch-linux
8383
LINUX_PATH ?= ~/projects/shadowfetch-4.0.0
8484

@@ -321,31 +321,11 @@ pre-release-check: iso-gate
321321
@ROOT=$(ROOT) CODENAME=$(CODENAME) REPO_DIR=$(REPO_DIR) REPO_MIN_VALID_FOR_SECONDS=$(REPO_MIN_VALID_FOR_SECONDS) \
322322
$(ROOT)/tools/pre_release_check.sh
323323

324-
# Upload ISO + APT repo to R2 via aws-cli (R2's S3-compatible API).
325-
# Requires: brew install awscli, and an R2 API token from the Cloudflare
326-
# dashboard (R2 → Manage R2 API Tokens → Create) exported as:
327-
# AWS_ACCESS_KEY_ID=<R2 Access Key ID>
328-
# AWS_SECRET_ACCESS_KEY=<R2 Secret Access Key>
329-
# aws-cli handles multipart automatically, so the 2.9 GB ISO uploads fine.
324+
# Publish only the accepted artifact from the authorized Linux source tree.
325+
# The publisher preserves historical ISO/package objects, verifies signatures,
326+
# and writes signed APT InRelease last. Credentials are process environment only.
330327
publish: pre-release-check
331-
@command -v aws >/dev/null || { echo "aws not installed. Run: brew install awscli" >&2; exit 1; }
332-
@test -n "$$AWS_ACCESS_KEY_ID" || { echo "AWS_ACCESS_KEY_ID not set (use your R2 API token Access Key ID)" >&2; exit 1; }
333-
@test -n "$$AWS_SECRET_ACCESS_KEY" || { echo "AWS_SECRET_ACCESS_KEY not set (use your R2 API token Secret Access Key)" >&2; exit 1; }
334-
@if [ ! -f $(ROOT)/$(ISO_NAME) ]; then echo "No ISO to publish ($(ROOT)/$(ISO_NAME))" >&2; exit 1; fi
335-
@if [ ! -f $(ROOT)/$(ISO_NAME).asc ]; then echo "No signature ($(ROOT)/$(ISO_NAME).asc) — run 'make sign'" >&2; exit 1; fi
336-
@echo ">>> Uploading small files first (sha256, signature, GPG key)"
337-
@aws --endpoint-url=$(R2_ENDPOINT) --region=$(R2_REGION) s3 cp $(ROOT)/$(ISO_NAME).sha256 s3://$(R2_BUCKET)/releases/$(ISO_NAME).sha256 --content-type "text/plain"
338-
@aws --endpoint-url=$(R2_ENDPOINT) --region=$(R2_REGION) s3 cp $(ROOT)/$(ISO_NAME).asc s3://$(R2_BUCKET)/releases/$(ISO_NAME).asc --content-type "application/pgp-signature"
339-
@aws --endpoint-url=$(R2_ENDPOINT) --region=$(R2_REGION) s3 cp $(REPO_DIR)/shadowfetch.gpg.asc s3://$(R2_BUCKET)/shadowfetch.gpg.asc --content-type "application/pgp-keys"
340-
@echo ">>> Mirroring APT repo (dists/ + pool/) to R2 apt/"
341-
@aws --endpoint-url=$(R2_ENDPOINT) --region=$(R2_REGION) s3 sync $(REPO_DIR)/dists s3://$(R2_BUCKET)/apt/dists --delete
342-
@aws --endpoint-url=$(R2_ENDPOINT) --region=$(R2_REGION) s3 sync $(REPO_DIR)/pool s3://$(R2_BUCKET)/apt/pool --delete
343-
@echo ">>> Uploading ISO ($(ISO_NAME), $$(du -h $(ROOT)/$(ISO_NAME) | cut -f1), multipart). Progress below."
344-
@aws --endpoint-url=$(R2_ENDPOINT) --region=$(R2_REGION) s3 cp $(ROOT)/$(ISO_NAME) s3://$(R2_BUCKET)/releases/$(ISO_NAME) --content-type "application/x-iso9660-image"
345-
@echo ">>> Done. Verify:"
346-
@echo " open $(PUBLIC_SITE)/"
347-
@echo " curl -I $(ARTIFACT_BASE)/download/$(ISO_NAME)"
348-
@echo " curl -sI $(ARTIFACT_BASE)/apt/dists/$(CODENAME)/InRelease"
328+
@python3 $(ROOT)/tools/publish_release_4_0_0.py --apply
349329

350330
qemu:
351331
qemu-system-x86_64 \
@@ -357,32 +337,20 @@ qemu:
357337
-drive file=$(ROOT)/$(ISO_NAME),media=cdrom,readonly=on \
358338
-boot d
359339

360-
# ---- Mac-side deploy flow ----
361-
# Run these from your Mac (where wrangler is logged in) to ship a release
362-
# that was built on $(LINUX_HOST).
363-
364-
# Pull the latest ISO + checksum + signature + reprepro repo back from the
365-
# Linux build box. Idempotent.
340+
# Pull a read-only local copy of already built release artifacts for inspection.
366341
sync-from-linux:
367-
@echo ">>> Pulling ISO + repo from $(LINUX_HOST):$(LINUX_PATH)"
368-
@rsync -avzhP $(LINUX_HOST):$(LINUX_PATH)/shadowfetch-*.iso $(ROOT)/ 2>/dev/null || echo "(no ISO yet on Linux box)"
369-
@rsync -avzhP $(LINUX_HOST):$(LINUX_PATH)/shadowfetch-*.iso.sha256 $(ROOT)/ 2>/dev/null || true
370-
@rsync -avzhP $(LINUX_HOST):$(LINUX_PATH)/shadowfetch-*.iso.asc $(ROOT)/ 2>/dev/null || true
371-
@rsync -avzh --delete $(LINUX_HOST):$(LINUX_PATH)/repo/ $(REPO_DIR)/
372-
@ls -lh $(ROOT)/shadowfetch-*.iso 2>/dev/null || echo "(no ISO present)"
342+
@rsync -avzhP $(LINUX_HOST):$(LINUX_PATH)/$(ISO_NAME) $(ROOT)/
343+
@rsync -avzhP $(LINUX_HOST):$(LINUX_PATH)/$(ISO_NAME).sha256 $(ROOT)/
344+
@rsync -avzhP $(LINUX_HOST):$(LINUX_PATH)/$(ISO_NAME).asc $(ROOT)/
373345

374-
# Deploy the shadowfetch-linux Worker. Requires `wrangler login` (one time) or CLOUDFLARE_API_TOKEN.
346+
# Artifact routing is stable across distro releases. Deploying a Worker is a
347+
# separate reviewed change, never an implicit side effect of publishing an ISO.
375348
deploy-worker:
376-
@command -v wrangler >/dev/null || { echo "wrangler not installed. Run: brew install cloudflare-wrangler2" >&2; exit 1; }
377-
@cd $(ROOT)/web/shadowfetch-linux-worker && wrangler deploy
349+
@echo "Artifact routing is unchanged. Review any Worker change separately." >&2
350+
@exit 1
378351

379-
# Full Mac-side ship: pull artifacts, publish to R2, deploy Worker.
380-
ship: sync-from-linux publish deploy-worker
381-
@echo ""
382-
@echo ">>> SHIPPED. Verify:"
383-
@echo " open $(PUBLIC_SITE)/"
384-
@echo " curl -I $(ARTIFACT_BASE)/download/$(ISO_NAME)"
385-
@echo " curl -sI $(ARTIFACT_BASE)/apt/dists/$(CODENAME)/InRelease"
352+
ship: publish
353+
@echo ">>> Accepted artifacts published. Complete public byte verification, GitHub release, and canonical Linux website deployment per RELEASE-4.0.0.md."
386354

387355
clean:
388356
-cd $(LB_DIR) && sudo lb clean

packages/shadowfetch-control-center/data/usr/share/shadowfetch/control-center/sfcc/grok_bot_page.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def __init__(self, open_route):
6565
("Native app, native sign-in", "Sign in inside Grok Bot. A model API key does not replace the app's account or subscription."),
6666
("A deliberate cloud connection", "Grok Bot uses its vendor's cloud services. Review its permissions and data handling before connecting projects."),
6767
("Verified installation", "Shadowfetch checks the pinned download, package identity and installed version. Setup requires administrator approval and enables the vendor's package update source."),
68-
("Your local missions stay visible", "Mission Control runs scoped code, private reports and media workflows. Grok Bot tasks and permissions are managed in the official native app."),
68+
("Your local missions stay visible", "Mission Control runs scoped code, source reports and media workflows. Grok Bot tasks and permissions are managed in the official native app."),
6969
)):
7070
card = Card()
7171
row = QVBoxLayout(card)

packages/shadowfetch-defaults/tests/test_fire_edition_2_1_5.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,7 @@ def _buzz_failure_env(self, root: Path, listener_port=None, free=100000):
15981598
"DISPLAY": ":1",
15991599
"HOME": str(home),
16001600
"XDG_RUNTIME_DIR": str(root / "run"),
1601+
"DBUS_SESSION_BUS_ADDRESS": f"unix:path={root}/run/bus",
16011602
"PATH": f"{fake}:/usr/bin:/bin",
16021603
"SHADOWFETCH_BUZZ_COMPOSE_SOURCE": str(compose),
16031604
})

packages/shadowfetch-missions/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ State progression is `queued → running → waiting-review → completed`. Erro
6060
A restarted worker marks interrupted executions failed and asks for inspection.
6161
It never automatically replays an interrupted code edit or external network action.
6262
An explicit Retry retains the original checkpoint. Previously published reports
63-
and individual media exports are resumed only when their recorded hashes match.
63+
and individual media exports are resumed only when their recorded input and output
64+
hashes match. Changed report sources or edited output refuse retry before inference;
65+
start a new mission to preserve those edits as a fresh recovery baseline.
6466

6567
Accept marks successful work reviewed. Undo restores the original workspace using
6668
its checkpoint, after proving no newer mission or manual file change intervened.

packages/shadowfetch-missions/data/usr/lib/shadowfetch/missions/sf_missions.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,7 @@ def __init__(self, store, mission):
387387
self.artifacts = []
388388
self.tests = []
389389
self.inferences = []
390+
self.preserve_recovery_index = False
390391

391392
def check(self):
392393
if self.store.get(self.mid)["cancel_requested"]:
@@ -524,9 +525,26 @@ def publish(self, name, content):
524525

525526
def report(self):
526527
previous = self.store.step(self.mid, "report-published")
527-
if previous and all(Path(p).is_file() and digest(p) == h for p, h in previous.items()):
528+
if previous:
529+
# Do not overwrite a person's updated sources or output on retry.
530+
# Keep the old recovery index so Undo also refuses those newer edits.
531+
self.preserve_recovery_index = True
532+
if not all(Path(p).is_file() and not Path(p).is_symlink() and digest(p) == h for p, h in previous.items()):
533+
raise MissionError("Published report files changed after this attempt. Preserve those edits and create a new mission with a fresh recovery checkpoint")
528534
self.artifacts.extend(previous)
529-
self.event("step-resumed", "Verified existing report hashes; skipped repeated inference")
535+
register = next((Path(p) for p in previous if Path(p).name == "sources.json"), None)
536+
if register is None:
537+
raise MissionError("The prior report has no source register. Create a new mission to establish a verified baseline")
538+
sources = self.input_text()
539+
try:
540+
original = {row["path"]: row["sha256"] for row in json.loads(register.read_text())}
541+
except (ValueError, KeyError, TypeError):
542+
raise MissionError("The prior report source register is invalid; create a new mission")
543+
current = {row["path"]: row["sha256"] for row in sources}
544+
if current != original:
545+
raise MissionError("Source inputs changed after this report. Create a new mission to preserve the updated files as a fresh recovery baseline; no inference was replayed")
546+
self.preserve_recovery_index = False
547+
self.event("step-resumed", "Verified report and source hashes; skipped repeated inference")
530548
return
531549
sources = self.input_text()
532550
context = "\n\n".join(f"[{source['id']}] {source['path']}\n" + "\n".join(f"{number}: {line}" for number, line in enumerate(source["text"].splitlines(), 1)) for source in sources)
@@ -701,13 +719,15 @@ def receipt(self, state, error=None):
701719
before = json.loads(before_path.read_text()) if before_path.exists() else {}
702720
try:
703721
after = tree_index(self.ws)
704-
atomic(self.directory / "changes.diff", difference(before, after))
705-
atomic(self.directory / "after-index.json", json.dumps(recovery_index(self.ws)))
722+
diff = difference(before, after) if before_path.exists() else "No recorded execution baseline; workspace changes cannot be attributed to this attempt.\n"
723+
atomic(self.directory / "changes.diff", diff)
724+
if not self.preserve_recovery_index:
725+
atomic(self.directory / "after-index.json", json.dumps(recovery_index(self.ws)))
706726
except OSError as exc:
707727
after = {}
708728
error = (error or "") + "; diff unavailable: " + clean(exc)
709729
records = [{"path": p, "sha256": digest(p), "bytes": Path(p).stat().st_size} for p in self.artifacts if Path(p).is_file()]
710-
receipt = {"schema": 1, "mission": self.mid, "title": self.mission["title"], "kind": self.mission["kind"], "state": state, "workspace": str(self.ws), "checkpoint": self.store.get(self.mid)["checkpoint"], "started_at": self.mission["updated_at"], "finished_at": now(), "runtime": self.mission["config"]["runtime"], "network": self.mission["config"]["network"], "error": error, "artifacts": records, "tests": self.tests, "inferences": self.inferences, "diff": str(self.directory / "changes.diff"), "review_required": state == "waiting-review", "limits": {"timeout_seconds": self.mission["config"]["timeout"], "sandbox_address_space_mb": 3072, "sandbox_processes": 96, "queue_concurrency": 1}, "recovery_scope": "Workspace files only; external network effects cannot be undone"}
730+
receipt = {"schema": 1, "mission": self.mid, "title": self.mission["title"], "kind": self.mission["kind"], "state": state, "workspace": str(self.ws), "checkpoint": self.store.get(self.mid)["checkpoint"], "started_at": self.mission["updated_at"], "finished_at": now(), "runtime": self.mission["config"]["runtime"], "network": self.mission["config"]["network"], "error": error, "artifacts": records, "tests": self.tests, "inferences": self.inferences, "diff": str(self.directory / "changes.diff"), "review_required": state == "waiting-review", "recovery_index_preserved": self.preserve_recovery_index, "limits": {"timeout_seconds": self.mission["config"]["timeout"], "sandbox_address_space_mb": 3072, "sandbox_processes": 96, "queue_concurrency": 1}, "recovery_scope": "Workspace files only; external network effects cannot be undone"}
711731
path = self.directory / "receipt.json"
712732
atomic(path, json.dumps(receipt, indent=2) + "\n")
713733
self.store.update(self.mid, receipt=str(path), artifacts=json.dumps([r["path"] for r in records]))

packages/shadowfetch-missions/tests/test_missions.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,33 @@ def test_resume_only_after_published_hash_verification(self):
170170
result = m.run_mission(self.store, mission["id"])
171171
self.assertEqual(result["state"], "waiting-review", result["error"])
172172
self.assertTrue(any(e["event"] == "step-resumed" for e in self.store.events(mission["id"])))
173+
def test_changed_report_inputs_refuse_resume_and_preserve_manual_edits(self):
174+
mission = self.create()
175+
with patch.object(m.Executor, "infer", return_value="Friday. [S1:L1]"):
176+
first = m.run_mission(self.store, mission["id"])
177+
report_path = next(Path(path) for path in first["artifacts"] if path.endswith("report.md"))
178+
report_before = report_path.read_text()
179+
self.store.update(mission["id"], state="failed")
180+
(self.ws / "facts.md").write_text("Updated launch is Saturday.\n")
181+
self.store.retry(mission["id"])
182+
with patch.object(m.Executor, "infer", side_effect=AssertionError("must not replay inference")):
183+
result = m.run_mission(self.store, mission["id"])
184+
self.assertEqual(result["state"], "failed")
185+
self.assertIn("Source inputs changed", result["error"])
186+
self.assertEqual(report_path.read_text(), report_before)
187+
self.assertEqual((self.ws / "facts.md").read_text(), "Updated launch is Saturday.\n")
188+
with self.assertRaisesRegex(m.MissionError, "changed after"):
189+
m.review(self.store, mission["id"], "undo")
190+
self.assertTrue(json.loads(Path(result["receipt"]).read_text())["recovery_index_preserved"])
191+
def test_missing_execution_baseline_does_not_claim_added_files(self):
192+
mission = self.create()
193+
executor = m.Executor(self.store, mission)
194+
executor.receipt("cancelled", "Cancelled before execution")
195+
diff = (self.store.directory(mission["id"]) / "changes.diff").read_text()
196+
self.assertIn("No recorded execution baseline", diff)
197+
self.assertNotIn("+ facts.md", diff)
198+
self.assertNotIn("after/facts.md", diff)
199+
173200
def test_undo_refuses_newer_manual_file_changes(self):
174201
mission = self.create()
175202
with patch.object(m.Executor, "infer", return_value="Friday. [S1:L1]"):

0 commit comments

Comments
 (0)