app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Release tooling: Azure Trusted Signing on the signing host (tools/sign_azure.py), insiders releases never touch the public manifest, release notes pushed; verb battery covers the 12 standard verbs and the remove_route dry run
4 files changed
+242−12
skills/kicad-bridge-publish/SKILL.md+20−3@@ -248,6 +248,23 @@ with that page's skills package (adom/wiki#170). The zip stays here. `tools/build_native_release.py --publish` signs `kicad-bridge.exe` as Adom Industries, Inc. as the LAST change to it, then checks that the exe inside the zip carries the signature, and refuses to publish otherwise. Windows 11-Smart App Control blocks unsigned exes; every native build up to 1.0.52 shipped unsigned. The signer is a private-tool outside this repo (`ADOM_SIGN_EXE`, default `~/bin/adom-sign-exe`); its account details never go in this repo,-a skill, a README or an issue. `--sign` signs a local build without publishing.+Smart App Control blocks unsigned exes; every native build up to 1.0.52 shipped unsigned. `--sign` signs a local+build without publishing. Two signers, one certificate:++- **A local helper** (`ADOM_SIGN_EXE`, default `~/bin/adom-sign-exe`) when one is installed.+- **Otherwise Azure Trusted Signing on the signing host** (from 1.0.59, `tools/sign_azure.py`), the path every+ Adom release uses (altium-bridge's `release.py`). Its private config (`config.json` + `sign-verify.ps1`) lives+ in `$ADOM_SIGNING_DIR`, else `~/.adom/signing/kicad-bridge`, else `~/.adom/signing/altium-bridge`. The script:+ 1. stages the exe on the host (`send_files`, a folder name without dots);+ 2. signs and verifies it there with one `run_script` (Status Valid, the expected subject, a Microsoft+ timestamp, `signtool verify /pa`);+ 3. pulls it back, checks the sha256 and the signer `CN = "Adom Industries, Inc."` here, and writes+ `rust/dist/signing-report.json`.++ `az_token_unavailable` means the host's Azure login lapsed: renew it with a device-code `az login` on the host+ and have the account owner approve it.++The account details never go in this repo, a skill, a README, an issue or a reply.++The public manifest is `adom-bridge-kicad-manifest.json` on the page (bridge.json `updateManifestUrl`). An+insiders release never writes or pushes it: only `tools/promote.py` does, with a person's words.
skills/kicad-bridge-test/run_verb_tests.py+41−2@@ -91,6 +91,21 @@ CHECKS = { # placement (1.0.3): live when the PCB editor has the board on the IPC API, the file otherwise "placement_state": lambda r: ok(r) and "revision" in r and isinstance(r.get("footprints"), list) and len(r["footprints"]) > 0 and "totalMm" in (r.get("ratsnest") or {}),+ # the EDA Bridge Standard board model and its companions (1.0.59), read from the fixture's file+ "standard_model": lambda r: ok(r) and r.get("units") == "mm" and len(r.get("pads") or []) > 0+ and len(r.get("revision") or "") == 16 and isinstance(r.get("unconnected"), int)+ and len(r.get("outlineEdges") or []) >= 3,+ "layer_stack": lambda r: ok(r) and (r.get("copperLayers") or 0) >= 2 and "dielectricUm" in (r.get("layers") or [{}])[0],+ "pour_state": lambda r: ok(r) and isinstance(r.get("zones"), list) and "filledAreaByLayerMm2" in r,+ "bodies": lambda r: ok(r) and (r.get("parts") or 0) > 0 and isinstance(r.get("bodies"), list)+ and len(r["bodies"]) + len(r.get("noBody") or []) >= r["parts"],+ "std_netlist": lambda r: ok(r) and (r.get("netCount") or 0) > 0 and isinstance(r.get("nets"), dict)+ and any(p.get("pins") for p in (r.get("parts") or [])),+ "board_summary": lambda r: ok(r) and (r.get("summary") or {}).get("components", 0) > 0,+ "count_list": lambda r: ok(r) and (r.get("count") or 0) > 0,+ "layers_in_use": lambda r: ok(r) and len(r.get("copperLayersInUse") or []) > 0,+ "component_data": lambda r: ok(r) and (r.get("component") or {}).get("pads", 0) > 0,+ "component_pins": lambda r: ok(r) and (r.get("count") or 0) > 0, "move_dry": lambda r: ok(r) and r.get("dryRun") is True and r.get("mutated") is False and "checks" in r and r.get("undoSteps") == 0, "taskbar_report": lambda r: ok(r) and r.get("errors") == 0 and ("already" in r or "written" in r),@@ -153,6 +168,19 @@ def matrix(w: dict) -> dict: "run_erc": dict(phase=2, args={"filePath": S}, timeout=200), "lint_board": dict(phase=2, args={"filePath": B}, timeout=200), "lint_schematic": dict(phase=2, args={"filePath": S}, timeout=200),+ # ---- phase 2a: the EDA Bridge Standard board model and companions (1.0.59), from the file+ "layer_stack": dict(phase=2, args={"filePath": B, "source": "file"}, check="layer_stack"),+ "pour_state": dict(phase=2, args={"filePath": B, "source": "file", "geometry": False}, check="pour_state"),+ "bodies": dict(phase=2, args={"filePath": B, "source": "file"}, check="bodies", timeout=120,+ note="3D model bounds from the files the footprints name; every part is a body or in noBody"),+ "netlist": dict(phase=2, args={"filePath": S, "parts": True}, check="std_netlist", timeout=120),+ "board_summary": dict(phase=2, args={"filePath": B, "source": "file"}, check="board_summary"),+ "list_nets": dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"),+ "list_designators": dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"),+ "copper_layers": dict(phase=2, args={"filePath": B, "source": "file"}, check="count_list"),+ "layers_in_use": dict(phase=2, args={"filePath": B, "source": "file"}, check="layers_in_use"),+ "component_data": dict(phase=2, args={"filePath": B, "source": "file", "designator": "U1"}, check="component_data"),+ "component_pins": dict(phase=2, args={"filePath": B, "source": "file", "designator": "U1"}, check="component_pins"), # ---- phase 2b: native-build verbs (Rust port). All read-only or on the fixture copy. "freerouting": dict(phase=1, args={"action": "status"}, timeout=60, note="status only: install is on demand and asks the user first"),@@ -783,7 +811,12 @@ def main(): if ok(st) and st.get("revision"): break time.sleep(5)- record("routing_state", st, ok(st) and bool(st.get("revision")) and isinstance(st.get("pads"), (list, str)))+ # the standard board model, live: the same board, the revision the edit verbs accept+ sm = ab(a.target, "kicad_routing_state", {"filePath": L, "model": "standard"}, timeout=150)+ std_ok = CHECKS["standard_model"](sm) and sm.get("source") == "live-editor"+ record("routing_state", {"legacy": st, "standard": {k: sm.get(k) for k in ("success", "source", "revision", "unconnected", "counts", "errorCode", "error")}},+ ok(st) and bool(st.get("revision")) and isinstance(st.get("pads"), (list, str)) and std_ok,+ None if std_ok else "the standard model did not read live") rev = [st.get("revision")] pads = st.get("pads") or [] if isinstance(pads, str):@@ -824,8 +857,14 @@ def main(): if routed is not None: record("route_net", routed, ok(routed) and bool(routed.get("itemIds"))) ids = routed.get("itemIds") or []+ # wiki #134: a dry run must leave the copper where it is+ d = step("remove_route", {"filePath": L, "expectedRevision": rev[0], "itemIds": ids, "dryRun": True, "reason": WHY}, chain=False)+ after = ab(a.target, "kicad_routing_state", {"filePath": L}, timeout=150)+ still = {x.get("uuid") for x in (after.get("segments") or [])} | {x.get("uuid") for x in (after.get("vias") or [])}+ dry_ok = ok(d) and d.get("mutated") is False and all(i in still for i in ids) r = step("remove_route", {"filePath": L, "expectedRevision": rev[0], "itemIds": ids, "reason": WHY})- record("remove_route", r, ok(r))+ record("remove_route", {"dryRun": d, "remove": r}, dry_ok and ok(r),+ None if dry_ok else "the dry run moved copper or did not answer mutated:false") else: well_formed = bool(last.get("errorCode")) and last.get("mutated") is not True record("route_net", last, well_formed, "no straight pad-to-pad hop passed DRC; the refusal path was exercised")
tools/build_native_release.py+20−7@@ -137,14 +137,21 @@ def main(): # Smart App Control blocks unsigned exes, so a published build is ALWAYS signed as Adom # Industries, Inc.; the signer is a private tool outside this public repo (ADOM_SIGN_EXE, # default ~/bin/adom-sign-exe). Every native build up to 1.0.52 shipped unsigned.+ # Two signers, the same certificate: a local helper (ADOM_SIGN_EXE, ~/bin/adom-sign-exe) where one is+ # installed, else Azure Trusted Signing on the signing host through Adom Bridge (tools/sign_azure.py, the+ # path every Adom release uses; its private config is outside this repo). signed = False if publish or "--sign" in sys.argv: signer = os.environ.get("ADOM_SIGN_EXE") or os.path.expanduser("~/bin/adom-sign-exe")- if not os.path.isfile(signer):- sys.exit(f"REFUSING: no exe signer at {signer}; an unsigned exe is never published")- r = subprocess.run([signer, str(stage / "kicad-bridge.exe"), "--bridge", "kicad-bridge"], text=True)- if r.returncode != 0:- sys.exit("REFUSING: signing failed (see above); nothing was published")+ if os.path.isfile(signer):+ r = subprocess.run([signer, str(stage / "kicad-bridge.exe"), "--bridge", "kicad-bridge"], text=True)+ if r.returncode != 0:+ sys.exit("REFUSING: signing failed (see above); nothing was published")+ else:+ sys.path.insert(0, str(ROOT / "tools"))+ import sign_azure+ report = sign_azure.sign(stage / "kicad-bridge.exe", ver)+ (dist / "signing-report.json").write_text(json.dumps(report, indent=2) + "\n") signed = authenticode_size(stage / "kicad-bridge.exe") > 0 if not signed: sys.exit("REFUSING: the signer reported success but the exe carries no signature")@@ -189,7 +196,7 @@ def publish_zip(ver, zpath, sha, notes): REF = "adom/kicad-bridge" # 1. The repo must tell the same story as the zip: push every rust source file. src = [str(p.relative_to(ROOT)) for p in sorted((RUST).rglob("*")) if p.is_file() and "target" not in p.parts and "dist" not in p.parts]- src += ["tools/build_native_release.py"]+ src += ["tools/build_native_release.py", "tools/sign_azure.py"] msg = f"{ver}: {notes}" failed = push_chunked(REF, src, msg, cwd=str(ROOT)) if failed:@@ -207,7 +214,9 @@ def publish_zip(ver, zpath, sha, notes): # ab reads every platforms entry as a download (url, sha256, size): one zip serves both OSes. dl = {"url": m["url"], "sha256": sha, "size": zpath.stat().st_size} m["platforms"] = {k: dict(v, **dl) for k, v in PLATFORMS.items() if v.get("supported")}- mpath.write_text(json.dumps(m, indent=2) + "\n")+ # NOT written back to mpath: the page's adom-bridge-kicad-manifest.json IS the public tier+ # (bridge.json updateManifestUrl), so a local copy at the insiders version is one careless push+ # away from promoting it. Only tools/promote.py moves it. # 4. INSIDERS tier only. The public manifest moves through tools/promote.py. ins = dict(m); ins["tier"] = "insiders" import tempfile@@ -223,6 +232,10 @@ def publish_zip(ver, zpath, sha, notes): prior = led.read_text(encoding="utf-8") head, _, rest = prior.partition("\n\n## ") led.write_text((head + "\n\n" + entry + ("## " + rest if rest else "")) if rest else (prior + entry), encoding="utf-8")+ # 6. The page repo keeps the ledger (it was left local before). Never the public manifest here.+ failed = push_chunked(REF, ["RELEASE_NOTES.md"], f"{ver}: release record", cwd=str(ROOT))+ if failed:+ print("WARNING: the RELEASE_NOTES.md push failed; push it by hand") print(f"\nSHIPPED TO INSIDERS ({ver}, native). Public tier untouched.") print(f" Promote with: python3 tools/promote.py {ver} --because \"<your words>\"") print(" Boxes pick it up on the 4 h poll, or now with bridge_install {manifestUrl: the insiders version.json, force: true}")
tools/sign_azure.pyadded+161@@ -0,0 +1,161 @@+#!/usr/bin/env python3+"""Authenticode-sign kicad-bridge.exe as Adom Industries, Inc. through Azure Trusted Signing, the path every+Adom release uses (adom/altium-bridge scripts/release.py, sign_pe_files).++The signing runs on a Windows host reached through Adom Bridge, under that host's own Azure login. Every+identifier (host, staging folder, endpoint, account, profile, tenant, timestamp URL, expected subject) and the+sign-verify.ps1 script live in a PRIVATE folder outside this public repo: $ADOM_SIGNING_DIR, else+~/.adom/signing/kicad-bridge, else ~/.adom/signing/altium-bridge (the same account signs every Adom bridge).+Nothing from it is ever written to this repo, a reply, a log line or an issue.++ python3 tools/sign_azure.py <exe> <version> # sign one exe in place; exits non-zero unless verified++The flow, each step a gate:+ 1. stage the exe and sign-verify.ps1 on the host (send_files; the folder name has no dots);+ 2. one run_script signs and verifies there: Get-AuthenticodeSignature Valid, the expected subject,+ a Microsoft timestamp, signtool verify /pa;+ 3. pull the signed exe back, check its sha256 matches the host's and differs from the unsigned build;+ 4. read its Authenticode signer here (openssl): CN = "Adom Industries, Inc.".+"""+import base64+import hashlib+import json+import os+import shutil+import struct+import subprocess+import sys+import tempfile+from pathlib import Path++SIGNER_ORG = "Adom Industries, Inc."+THREAD = "kicad-bridge release signing"+++def signing_dir():+ env = os.environ.get("ADOM_SIGNING_DIR")+ if env:+ return Path(env)+ for name in ("kicad-bridge", "altium-bridge"):+ d = Path.home() / ".adom" / "signing" / name+ if (d / "config.json").is_file():+ return d+ return Path.home() / ".adom" / "signing" / "kicad-bridge"+++def authenticode_subjects(path):+ """Certificate subjects in a PE's Authenticode signature, read without Windows: the security data+ directory points at a WIN_CERTIFICATE holding PKCS#7 DER. [] means unsigned."""+ b = Path(path).read_bytes()+ pe = struct.unpack_from("<I", b, 0x3C)[0]+ if b[pe:pe + 4] != b"PE\0\0":+ raise SystemExit(f"{path}: not a PE file")+ opt = pe + 24+ magic = struct.unpack_from("<H", b, opt)[0]+ dirs = opt + (112 if magic == 0x20B else 96)+ off, size = struct.unpack_from("<II", b, dirs + 4 * 8)+ if not off or not size:+ return []+ length, _rev, ctype = struct.unpack_from("<IHH", b, off)+ if ctype != 2:+ return []+ with tempfile.NamedTemporaryFile(suffix=".p7b", delete=False) as f:+ f.write(b[off + 8:off + length])+ r = subprocess.run(["openssl", "pkcs7", "-inform", "DER", "-in", f.name, "-print_certs", "-noout"], capture_output=True, text=True)+ os.unlink(f.name)+ return [l[len("subject="):].strip() for l in r.stdout.splitlines() if l.startswith("subject=")]+++def assert_signed(path):+ subs = authenticode_subjects(path)+ if not any(f'CN = "{SIGNER_ORG}"' in s for s in subs):+ raise SystemExit(f"REFUSED: {path} is not Authenticode-signed by {SIGNER_ORG} (found {subs or 'no signature'})")+ return subs+++def ab_json(*args, timeout=600):+ out = subprocess.run(["adom-bridge", *args], capture_output=True, text=True, timeout=timeout).stdout+ d = json.loads(out[out.find("{"):]) if "{" in out else {}+ o = d.get("output")+ if isinstance(o, str) and o.strip().startswith("{"):+ try:+ d = json.loads(o)+ except ValueError:+ pass+ return d+++def sign(exe, ver):+ """Sign `exe` in place on the signing host; returns the signing report (no private values in it)."""+ exe = Path(exe)+ sd = signing_dir()+ cfg_p, ps1 = sd / "config.json", sd / "sign-verify.ps1"+ if not cfg_p.is_file() or not ps1.is_file():+ raise SystemExit(f"REFUSED: private signing config missing at {sd} (config.json + sign-verify.ps1). It is "+ f"deliberately not in this public repo; ask Colby to copy it over privately.")+ cfg = json.loads(cfg_p.read_text())+ host = cfg["host"]+ stage = cfg["stageDir"].replace("%LOCALAPPDATA%", cfg.get("localAppData", r"C:\Users\cknox\AppData\Local")) \+ + "\\kicad-build-" + ver.replace(".", "_") # a dotted folder name reads as a file path to send_files+ unsigned_sha = hashlib.sha256(exe.read_bytes()).hexdigest()+ work = Path(tempfile.mkdtemp(prefix="kicad-sign-"))+ name = "kicad-bridge.exe"+ shutil.copy2(exe, work / name)+ shutil.copy2(ps1, work / "sign-verify.ps1")+ print(f"== sign {name} {ver} on the signing host ==")+ sent = ab_json("--target", host, "--ai-thread", THREAD, "send_files", json.dumps({+ "filePaths": [str(work / name), str(work / "sign-verify.ps1")], "dest": stage, "destIsFolder": True,+ "reason": f"stage kicad-bridge {ver} for Authenticode signing"}))+ if int(sent.get("filesReceived") or 0) != 2:+ shutil.rmtree(work, ignore_errors=True)+ raise SystemExit(f"REFUSED: the signing host did not receive both files: {sent.get('error') or sent.get('_hint') or 'no reply'}")+ call = (f"(& '{stage}\\sign-verify.ps1' -Exe '{stage}\\{name}' -Endpoint '{cfg['endpoint']}' -Account '{cfg['account']}' "+ f"-Profile '{cfg['profile']}' -Tenant '{cfg['tenant']}' -Timestamp '{cfg['timestampUrl']}' | Out-String)")+ cmd = f"$r = @({call}); '[' + ($r -join ',') + ']'"+ r = ab_json("--target", host, "--ai-thread", THREAD, "run_script", json.dumps({+ "interpreter": "powershell", "scriptB64": base64.b64encode(cmd.encode()).decode(), "timeout": 540,+ "reason": f"Authenticode-sign kicad-bridge {ver} as {SIGNER_ORG}, then Get-AuthenticodeSignature + signtool verify"}), timeout=900)+ so = r.get("stdout") or ""+ try:+ v = json.loads(so[so.find("["):])[0]+ except (ValueError, IndexError):+ shutil.rmtree(work, ignore_errors=True)+ raise SystemExit(f"REFUSED: the signing script gave no verdict (stdout {len(so)} chars, stderr {len(r.get('stderr') or '')} chars, error {r.get('errorCode') or r.get('error') or '-'})")+ if v.get("error"):+ shutil.rmtree(work, ignore_errors=True)+ raise SystemExit(f"REFUSED: the signing host cannot sign ({v['error']}): {v.get('detail', '')}\n fix: {v.get('fix', '')}")+ checks = {+ "Status Valid": v.get("status") == "Valid",+ "signer subject": cfg["expectSubject"] in (v.get("subject") or ""),+ "timestamped": "Microsoft" in (v.get("timestamper") or ""),+ "signtool verify /pa": "Successfully verified" in (v.get("signtoolVerify") or ""),+ }+ if not all(checks.values()):+ shutil.rmtree(work, ignore_errors=True)+ raise SystemExit(f"REFUSED: signature checks failed on the host: {checks}")+ pulled = ab_json("--target", host, "--ai-thread", THREAD, "pull_file", json.dumps({+ "filePaths": [f"{stage}\\{name}"], "saveTo": str(work / "back"), "reason": f"pull the signed kicad-bridge {ver} back for packaging"}))+ files = pulled.get("files") or []+ if not files:+ shutil.rmtree(work, ignore_errors=True)+ raise SystemExit(f"REFUSED: could not pull the signed exe back: {pulled.get('error') or 'no files'}")+ back = Path(files[0]["path"])+ signed_sha = hashlib.sha256(back.read_bytes()).hexdigest()+ if v.get("sha256") and signed_sha != v["sha256"].lower():+ raise SystemExit("REFUSED: the signed exe changed in transit (host and local sha256 differ)")+ if signed_sha == unsigned_sha:+ raise SystemExit("REFUSED: the pulled-back exe is byte-identical to the unsigned build")+ subs = assert_signed(back)+ shutil.copy2(back, exe)+ shutil.rmtree(work, ignore_errors=True)+ report = {"file": name, "version": ver, "status": v["status"], "subject": SIGNER_ORG, "timestamper": v["timestamper"],+ "signtoolVerify": "Successfully verified", "sha256Unsigned": unsigned_sha, "sha256": signed_sha,+ "localSubjects": [s for s in subs if SIGNER_ORG in s]}+ print(" " + ", ".join(f"{k} {'ok' if ok else 'FAIL'}" for k, ok in checks.items()) + f"; verified again here, sha256 {signed_sha[:12]}")+ return report+++if __name__ == "__main__":+ if len(sys.argv) != 3:+ sys.exit(__doc__)+ print(json.dumps(sign(sys.argv[1], sys.argv[2]), indent=2))