Skip to content

Commit aead4aa

Browse files
authored
Merge pull request #178 from Aperivue/feat/v46-self-update-pr1b-updater
feat(updater): one-click classroom self-update — verified download + safe extraction (PR-1b)
2 parents 4d98d96 + ecbe143 commit aead4aa

10 files changed

Lines changed: 830 additions & 10 deletions

File tree

.github/workflows/validate.yml

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ jobs:
4343
- name: Transactional installer crash-recovery + legacy-migration test
4444
run: python3 installers/tests/test_txn.py
4545

46+
- name: Updater verify / safe-extract / check-update test (offline)
47+
run: python3 installers/tests/test_update.py
48+
4649
- name: install.py transactional self-test (no host/state dir touched)
4750
run: python3 installers/install.py --self-test
4851

@@ -219,19 +222,26 @@ jobs:
219222
- name: npm pack content audit (real pack, package/ prefix normalized)
220223
run: python3 scripts/check_npm_package_contents.py --real
221224

222-
# Cross-platform safety for the transactional installer (PR-1a). Ubuntu-only CI cannot
223-
# assert Windows path/journal/os.replace behavior; these run the crash-recovery +
224-
# legacy-migration tests and the transactional self-test on Windows. (Hash-based manifest
225-
# --check stays Ubuntu-only — it is sensitive to checkout line endings, not OS behavior.)
226-
foundation-windows:
227-
runs-on: windows-latest
225+
# Cross-platform safety for the transactional installer + updater (PR-1a/PR-1b). Ubuntu-only CI
226+
# cannot assert macOS/Windows path/journal/os.replace/extraction behavior; this matrix runs the
227+
# crash-recovery + legacy-migration tests, the updater verify/safe-extract tests, and the
228+
# transactional self-test on macOS + Windows (Ubuntu is covered by the `validate` job). Hash-based
229+
# manifest --check stays Ubuntu-only — it is sensitive to checkout line endings, not OS behavior.
230+
foundation-os:
231+
strategy:
232+
fail-fast: false
233+
matrix:
234+
os: [macos-latest, windows-latest]
235+
runs-on: ${{ matrix.os }}
228236
steps:
229237
- uses: actions/checkout@v4
230238
- name: Set up Python
231239
uses: actions/setup-python@v5
232240
with:
233241
python-version: "3.11"
234-
- name: Transactional installer crash-recovery + legacy-migration test (Windows)
242+
- name: Transactional installer crash-recovery + legacy-migration test
235243
run: python installers/tests/test_txn.py
236-
- name: install.py transactional self-test (Windows)
244+
- name: Updater verify / safe-extract / check-update test (offline)
245+
run: python installers/tests/test_update.py
246+
- name: install.py transactional self-test
237247
run: python installers/install.py --self-test

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,26 @@ See [docs/classroom_distribution_plan.md](docs/classroom_distribution_plan.md) a
526526

527527
> **Tip:** Not sure which skill to use? Start with `/orchestrate` -- it will classify your request and route you to the right tool.
528528
529+
## Updating
530+
531+
MedSci Skills updates often. You do **not** need GitHub, git, or the command line to stay current.
532+
533+
- **One click (recommended for the classroom install).** After installing, an updater is placed at
534+
`~/.medsci-skills/updater/` (and, if you chose `--desktop-launcher`, an **"Update MedSci Skills"**
535+
icon on your Desktop). Double-click it: it downloads the latest release from GitHub, verifies it,
536+
and re-installs — transactionally, so an interrupted update never corrupts your install.
537+
- **Already installed an old copy?** Re-download the latest classroom ZIP **once** and double-click
538+
the installer; from then on the one-click updater is in place for every future update.
539+
- **Terminal users:** `npx medsci-skills@latest install` always installs the latest.
540+
- **Just checking:** `python3 installers/install.py --check-update` reports whether a newer version
541+
is available and installs nothing.
542+
- **Claude Code plugin marketplace:** third-party marketplace **auto-update is off by default**
543+
enable it in Claude Code or run a manual plugin update.
544+
545+
Updates connect only to GitHub, send no information about your machine or work, and create no
546+
telemetry or tracking. Modified skills are backed up before an update and never auto-deleted. See
547+
the [update privacy & data notice](docs/update_privacy.md).
548+
529549
## Key Features
530550

531551
### Autonomous E2E Pipeline

docs/update_privacy.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Updating — privacy & data notice
2+
3+
The self-updater exists so physician-researchers can stay current without using GitHub, git,
4+
or a terminal. It is intentionally minimal about data.
5+
6+
## What it connects to
7+
8+
- **`api.github.com`** — a single public, read-only GitHub REST call to look up the latest
9+
release of `Aperivue/medsci-skills` (tag + the release asset's name and SHA-256 digest).
10+
- **GitHub's release-asset host** (`*.githubusercontent.com`) — to download the classroom ZIP
11+
for your operating system over HTTPS.
12+
13+
No other servers are contacted.
14+
15+
## What is (and is not) sent
16+
17+
- The only thing sent is an ordinary HTTPS GET request (with a generic `User-Agent:
18+
medsci-skills-updater`). GitHub, like any web host, sees your IP address and the request.
19+
- **Nothing about your machine or work is collected or transmitted.** The updater does **not**
20+
read or send your working directory, file contents, transcripts, session data, or skill usage.
21+
There is **no telemetry, no analytics, and no unique install identifier**.
22+
- Because GitHub is a US-based service, the network request may be processed outside your country.
23+
24+
## What is stored locally
25+
26+
Everything the updater writes stays on your computer under `~/.medsci-skills/`:
27+
28+
- `targets/<target>/` — what version is installed where (an installed-manifest + small state file).
29+
- `backups/<timestamp>/<target>/` — snapshots of any skill you had modified, kept before an update
30+
overwrites it. **These are never deleted automatically** — remove them yourself when you no longer
31+
need them.
32+
- `updater/` — the one-click updater itself.
33+
- `update_check.json` — a small cache so the update check runs at most once a day.
34+
35+
Install logs are written next to the installer and **mask your home directory as `~`**.
36+
37+
## Checking, opting out, and uninstalling
38+
39+
- **Check only:** `python3 installers/install.py --check-update` reports whether a newer version
40+
exists and installs nothing.
41+
- **Skip the per-session update check** (if you opted into it): set the environment variable
42+
`MEDSCI_NO_UPDATE_CHECK=1`.
43+
- **Uninstall the updater / state:** delete the `~/.medsci-skills/` folder (and any Desktop
44+
"Update MedSci Skills" launcher you chose to create). The installed skills under
45+
`~/.claude/skills` / `~/.agents/skills` are separate and remain until you remove them.
46+
47+
## Security note (honest scope)
48+
49+
Downloading a release and running its bundled installer is, by nature, **running code from
50+
GitHub**. The updater verifies the download's SHA-256 against the digest GitHub's API reports for
51+
that release asset, which **detects a corrupted or tampered-in-transit download** — it does **not**
52+
protect against a compromised GitHub account or a malicious official release. Treat updates with the
53+
same trust you place in this GitHub repository.

installers/install.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,13 +194,30 @@ def parse_args() -> argparse.Namespace:
194194
action="store_true",
195195
help="Simulate installs into temp dirs, assert all skills are discoverable, and touch no host directory. Exits 0 on pass.",
196196
)
197+
parser.add_argument(
198+
"--check-update",
199+
action="store_true",
200+
help="Report whether a newer release is available (connects to GitHub; installs nothing).",
201+
)
202+
parser.add_argument(
203+
"--desktop-launcher",
204+
action="store_true",
205+
help="With your consent, also place an 'Update MedSci Skills' launcher on your Desktop.",
206+
)
197207
return parser.parse_args()
198208

199209

200210
def main() -> int:
201211
args = parse_args()
202212
if args.self_test:
203213
return run_self_test()
214+
if args.check_update:
215+
try:
216+
import update # noqa: PLC0415 - optional, only when explicitly requested
217+
return update.check_update(medsci_txn.state_home())
218+
except Exception as exc: # noqa: BLE001
219+
print(f"MedSci Skills: update check unavailable ({exc}).", file=sys.stderr)
220+
return 1
204221
log_lines: list[str] = []
205222
log("MedSci Skills Installer", log_lines)
206223
log(f"Repository: {REPO_ROOT}", log_lines)
@@ -228,6 +245,17 @@ def main() -> int:
228245
failures.append("cursor")
229246
log(f"\n[cursor] FAILED: {exc}", log_lines)
230247

248+
# Place the one-click updater under ~/.medsci-skills/updater/ so a future update needs no
249+
# GitHub/terminal even if this download folder is deleted (best-effort; never fatal).
250+
if not args.dry_run:
251+
try:
252+
import update # noqa: PLC0415
253+
update.install_updater_home(REPO_ROOT, medsci_txn.state_home(),
254+
lambda m: log(m, log_lines),
255+
desktop=args.desktop_launcher)
256+
except Exception as exc: # noqa: BLE001
257+
log(f"\n[updater] could not install the one-click updater ({exc}); updates still work via re-running the installer.", log_lines)
258+
231259
if failures:
232260
log(f"\nCompleted with errors on: {', '.join(failures)}. Other targets are fully installed.", log_lines)
233261
log("If this happened during class, send the install log to the instructor.", log_lines)

installers/tests/test_update.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#!/usr/bin/env python3
2+
"""Updater tests for installers/update.py — fully offline (network + install injected).
3+
4+
Builds a synthetic classroom ZIP (single root, skills + installers + a matching
5+
distribution_files.json), fakes the GitHub API (digest = the ZIP's sha256) and the byte
6+
download, and mocks the install step (so no real host dir is touched). Covers the happy
7+
path + the supply-chain guards (digest mismatch, fail-closed no-digest, traversal, symlink,
8+
duplicate, unexpected file, hash/size mismatch, zip-bomb caps, draft/prerelease) and the
9+
check_update cache/semver logic. Run: python3 installers/tests/test_update.py
10+
"""
11+
from __future__ import annotations
12+
13+
import hashlib
14+
import io
15+
import json
16+
import os
17+
import sys
18+
import tempfile
19+
import zipfile
20+
from pathlib import Path
21+
22+
HERE = Path(__file__).resolve().parent
23+
sys.path.insert(0, str(HERE.parent))
24+
import update as U # noqa: E402
25+
import medsci_txn # noqa: E402
26+
27+
PASS = 0
28+
FAIL = 0
29+
ROOT = "medsci-skills-classroom-2.0.0"
30+
31+
32+
def check(label, cond):
33+
global PASS, FAIL
34+
print(f" {'PASS' if cond else 'FAIL'} {label}")
35+
PASS += (1 if cond else 0)
36+
FAIL += (0 if cond else 1)
37+
38+
39+
def _sha(b: bytes) -> str:
40+
return hashlib.sha256(b).hexdigest()
41+
42+
43+
def build_payload(tmp: Path) -> dict[str, bytes]:
44+
"""Return {relpath: bytes} for a minimal valid classroom payload (skills + installers +
45+
README_FIRST), with metadata/distribution_files.json matching the skills/installers/readme."""
46+
files: dict[str, bytes] = {}
47+
files["README_FIRST.md"] = b"# MedSci Skills\n"
48+
files["skills/demo/SKILL.md"] = b"# demo\n"
49+
files["skills/demo/x.txt"] = b"data\n"
50+
# ship the real installer modules so an extracted install.py would be runnable + the updater
51+
# can self-install (update.py + medsci_txn.py) from the payload.
52+
for n in ("install.py", "medsci_txn.py", "update.py"):
53+
files[f"installers/{n}"] = (HERE.parent / n).read_bytes()
54+
# inventory excludes the metadata manifests themselves (matches gen scope)
55+
inv = [{"path": p, "size": len(b), "sha256": _sha(b)} for p, b in sorted(files.items())]
56+
files["metadata/distribution_files.json"] = (json.dumps({"schema_version": 1, "files": inv}) + "\n").encode()
57+
files["metadata/distribution_manifest.json"] = (json.dumps(
58+
{"schema_version": 1, "version": "2.0.0", "owned_skills": ["demo"]}) + "\n").encode()
59+
return files
60+
61+
62+
def make_zip(files: dict[str, bytes], root: str = ROOT, extra: dict[str, bytes] | None = None,
63+
symlink: str | None = None, dup: bool = False) -> bytes:
64+
buf = io.BytesIO()
65+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
66+
for rel, b in files.items():
67+
zf.writestr(f"{root}/{rel}", b)
68+
for rel, b in (extra or {}).items():
69+
zf.writestr(f"{root}/{rel}", b)
70+
if symlink:
71+
info = zipfile.ZipInfo(f"{root}/{symlink}")
72+
info.external_attr = (0o120777 << 16) # symlink mode
73+
zf.writestr(info, "skills/demo")
74+
if dup:
75+
zf.writestr(f"{root}/skills/DEMO/SKILL.md", b"dup\n") # case-insensitive dup of skills/demo
76+
return buf.getvalue()
77+
78+
79+
def fake_api(zip_bytes: bytes, asset="medsci-skills-classroom-macos.zip", tag="v2.0.0",
80+
draft=False, prerelease=False, digest=True):
81+
def get_json(_url):
82+
a = {"name": asset, "browser_download_url": "https://release-assets.githubusercontent.com/x.zip"}
83+
if digest:
84+
a["digest"] = "sha256:" + _sha(zip_bytes)
85+
return {"tag_name": tag, "draft": draft, "prerelease": prerelease, "assets": [a]}
86+
return get_json
87+
88+
89+
def run():
90+
asset = "medsci-skills-classroom-macos.zip"
91+
with tempfile.TemporaryDirectory(prefix="medsci-upd-") as tmp:
92+
base = Path(tmp)
93+
files = build_payload(base)
94+
zbytes = make_zip(files)
95+
96+
# --- happy path: resolve + verify + safe-extract + install (mocked) + updater home ---
97+
home = base / "home"
98+
installs = []
99+
rc = U.do_update(home, lambda m: None,
100+
get_json=fake_api(zbytes, asset=asset),
101+
get_bytes=lambda url: zbytes,
102+
asset_name=asset,
103+
run_install=lambda inst: installs.append(inst) or 0)
104+
check("happy path returns tag v2.0.0", rc["tag"] == "v2.0.0")
105+
check("happy path called the extracted installer", len(installs) == 1 and installs[0].name == "install.py")
106+
check("updater installed to ~/.medsci-skills/updater/", (home / "updater" / "update.py").is_file())
107+
108+
# --- fail closed: no digest ---
109+
try:
110+
U.resolve_latest(fake_api(zbytes, asset=asset, digest=False), asset)
111+
check("no-digest fails closed", False)
112+
except U.UpdateError:
113+
check("no-digest fails closed", True)
114+
115+
# --- draft/prerelease excluded ---
116+
for flag in ("draft", "prerelease"):
117+
try:
118+
U.resolve_latest(fake_api(zbytes, asset=asset, **{flag: True}), asset)
119+
check(f"{flag} release rejected", False)
120+
except U.UpdateError:
121+
check(f"{flag} release rejected", True)
122+
123+
# --- digest mismatch (tampered bytes) ---
124+
try:
125+
U.do_update(home, lambda m: None, get_json=fake_api(zbytes, asset=asset),
126+
get_bytes=lambda url: zbytes + b"X", asset_name=asset, run_install=lambda i: 0)
127+
check("digest mismatch aborts", False)
128+
except U.UpdateError:
129+
check("digest mismatch aborts", True)
130+
131+
# --- safe_extract guards (extract a verified zip's bytes directly) ---
132+
_, inv = U.read_inventory_from_zip(zbytes)
133+
134+
def extracts_ok(zb):
135+
d = base / ("ex-%d" % len(os.listdir(base)))
136+
d.mkdir()
137+
U.safe_extract(zb, d, U.read_inventory_from_zip(zb)[1])
138+
139+
def rejects(label, zb):
140+
try:
141+
d = base / ("rej-%d" % len(os.listdir(base)))
142+
d.mkdir()
143+
U.safe_extract(zb, d, inv)
144+
check(label, False)
145+
except U.UpdateError:
146+
check(label, True)
147+
148+
check("clean zip extracts", (extracts_ok(zbytes) is None))
149+
rejects("traversal entry rejected", make_zip(files, extra={"../evil.txt": b"x"}))
150+
rejects("unexpected file (not in inventory) rejected", make_zip(files, extra={"skills/demo/EXTRA.bin": b"x"}))
151+
rejects("symlink entry rejected", make_zip(files, symlink="link"))
152+
rejects("case-insensitive duplicate rejected", make_zip(files, dup=True))
153+
# hash/size mismatch: change a payload file's bytes but keep the old inventory
154+
tampered = dict(files)
155+
tampered["skills/demo/x.txt"] = b"TAMPERED-DIFFERENT-LENGTH"
156+
rejects("payload hash/size mismatch rejected", make_zip(tampered))
157+
158+
# zip-bomb caps
159+
old_entries, old_total = U.MAX_ENTRIES, U.MAX_TOTAL_UNCOMPRESSED
160+
try:
161+
U.MAX_TOTAL_UNCOMPRESSED = 10
162+
rejects("total-size cap rejects", zbytes)
163+
finally:
164+
U.MAX_TOTAL_UNCOMPRESSED = old_total
165+
try:
166+
U.MAX_ENTRIES = 1
167+
rejects("entry-count cap rejects", zbytes)
168+
finally:
169+
U.MAX_ENTRIES = old_entries
170+
171+
# --- check_update semver + cache ---
172+
h2 = base / "home2"
173+
medsci_txn.atomic_write_json(h2 / "targets" / "claude" / "state.json", {"installed_version": "1.0.0"})
174+
rc = U.check_update(h2, get_json=fake_api(zbytes, asset=asset, tag="v2.0.0"), asset_name=asset, force=True)
175+
check("check_update: newer -> UPDATE_AVAILABLE", rc == U.CHK_UPDATE_AVAILABLE)
176+
rc = U.check_update(h2, get_json=fake_api(zbytes, asset=asset, tag="v1.0.0"), asset_name=asset, force=True)
177+
check("check_update: same -> UP_TO_DATE", rc == U.CHK_UP_TO_DATE)
178+
# cache fresh -> no network call
179+
called = {"n": 0}
180+
def counting(_u):
181+
called["n"] += 1
182+
return fake_api(zbytes, asset=asset, tag="v2.0.0")(_u)
183+
U.check_update(h2, get_json=counting, asset_name=asset, force=True) # populates cache
184+
U.check_update(h2, get_json=counting, asset_name=asset) # should use cache
185+
check("check_update: fresh cache avoids 2nd network call", called["n"] == 1)
186+
# unknown local
187+
rc = U.check_update(base / "home_empty", get_json=fake_api(zbytes, asset=asset), asset_name=asset, force=True)
188+
check("check_update: unknown install -> UNKNOWN_LOCAL", rc == U.CHK_UNKNOWN_LOCAL)
189+
# network failure
190+
def boom(_u):
191+
raise U.UpdateError("offline")
192+
rc = U.check_update(h2, get_json=boom, asset_name=asset, force=True)
193+
check("check_update: network failure -> NETWORK_FAILURE", rc == U.CHK_NETWORK_FAILURE)
194+
195+
print("----")
196+
print(f"test_update: {PASS} passed, {FAIL} failed")
197+
return 1 if FAIL else 0
198+
199+
200+
if __name__ == "__main__":
201+
sys.exit(run())

0 commit comments

Comments
 (0)