Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
f98d317
Move PR preview service into Web Simulator
schnuartz-ai Oct 1, 2026
c0ff238
Harden paired browser preview service
schnuartz-ai Oct 2, 2026
34df94e
Document automatic fork PR previews
schnuartz-ai Oct 2, 2026
a55a63f
Keep PR-generated JavaScript out of previews
schnuartz-ai Oct 2, 2026
bd5a30d
Isolate preview verification from PR builds
schnuartz-ai Oct 4, 2026
f4c036a
Remove pip installs from trusted runtime build
schnuartz-ai Oct 4, 2026
bf36112
Generate browser manifest before preflight check
schnuartz-ai Oct 4, 2026
0d4c565
Pass action metadata into preview verification
schnuartz-ai Oct 4, 2026
accb8d7
Recreate trusted runtime source metadata
schnuartz-ai Oct 4, 2026
c4176e2
Merge upstream main into PR preview service
schnuartz-ai Oct 4, 2026
df5b252
Generate browser manifest before checking freeze inputs
schnuartz-ai Oct 4, 2026
06ebee5
Fix card browser test after developer option gating
schnuartz-ai Oct 4, 2026
3ce6c64
Add Virtual USB Connection panel to simulator
schnuartz-ai Oct 4, 2026
4a574a1
Link Virtual Host downloads to latest release
schnuartz-ai Oct 4, 2026
2f90052
Rename simulator note to avoid removed hardware class
schnuartz-ai Oct 4, 2026
23052f6
Remove seed warning and trusted sites link
schnuartz-ai Oct 4, 2026
e9ff8c8
Publish immutable previews and report PR results
schnuartz-ai Oct 7, 2026
da49f0e
Retain only the latest PR firmware artifact
schnuartz-ai Oct 8, 2026
ce80a93
Keep only the latest PR browser preview
schnuartz-ai Oct 8, 2026
8145176
feat: define PR-and-commit firmware artifact naming
schnuartz-ai Oct 8, 2026
6a059ae
feat: stage commit-named firmware binaries and ZIP metadata
schnuartz-ai Oct 8, 2026
c093fd4
feat: publish firmware ZIP under PR number and 12-character source SHA
schnuartz-ai Oct 8, 2026
2474077
security: verify commit-named firmware contents against trusted PR me…
schnuartz-ai Oct 8, 2026
2f536ec
fix: allow safe cleanup of commit-named firmware artifacts
schnuartz-ai Oct 8, 2026
cc96bcd
test: verify PR-and-commit names in firmware publisher fixtures
schnuartz-ai Oct 8, 2026
0698b4b
test: cover named firmware artifacts and cleanup
schnuartz-ai Oct 8, 2026
a72717d
test: cover named firmware artifacts and cleanup
schnuartz-ai Oct 8, 2026
e64ee22
docs: describe PR-and-commit firmware ZIP names
schnuartz-ai Oct 8, 2026
c037104
test: cover exact firmware ZIP and BIN/HEX names plus provenance
schnuartz-ai Oct 8, 2026
9139ec3
test: expect commit-named firmware in packager fixture
schnuartz-ai Oct 8, 2026
4b1ca43
test: isolate each commit-named firmware fixture directory
schnuartz-ai Oct 8, 2026
af2939c
fix(preview): post replacement App comment before pruning old comments
schnuartz-ai Oct 8, 2026
69fda7c
test(preview): protect existing PR comments on post and cleanup failures
schnuartz-ai Oct 8, 2026
35a750c
docs(preview): document App write-back boundary and required main pro…
schnuartz-ai Oct 8, 2026
d9742d7
docs(preview): remove obsolete polling description from README
schnuartz-ai Oct 8, 2026
292df9e
fix(preview): keep reporter POST contract explicit
schnuartz-ai Oct 8, 2026
d9ae70e
feat(preview): use verified external machine-user token and update on…
schnuartz-ai Oct 8, 2026
3d019f2
ci(preview): use trusted finalizer machine-user secret instead of bro…
schnuartz-ai Oct 8, 2026
dbbbd0d
fix(ui): show firmware version plus exact Specter source commit benea…
schnuartz-ai Oct 8, 2026
5779f24
test(ui): verify under-device version, source SHA and exact GitHub co…
schnuartz-ai Oct 8, 2026
9057245
fix(preview): check real GitHub author login in POST and PATCH responses
schnuartz-ai Oct 8, 2026
2f4eec9
test(preview): verify stable machine-user comment, provenance, owners…
schnuartz-ai Oct 8, 2026
3e78760
test(ci): assert Bot PAT exists only in trusted finalizer and forbids…
schnuartz-ai Oct 8, 2026
f4e6223
docs(preview): document verified read-only machine-user bot and produ…
schnuartz-ai Oct 8, 2026
c750dfa
docs(preview): describe machine-user single-comment architecture
schnuartz-ai Oct 8, 2026
bad648d
fix(preview): isolate SHA-specific PR routes and harden reporter checks
schnuartz-ai Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ jobs:
run: npm ci --prefix web
- name: Run simulator unit tests
run: |
python3 web/tests/test-resolve-build-target.py
python3 web/tests/test-trigger-pr.py
for test in web/tests/test-*.py; do
if [[ "$test" == *test-browser-manifest.py ]]; then continue; fi
python3 "$test"
done
- name: Run build-independent JavaScript tests
run: |
node web/tests/test-demo-data.mjs
python3 web/tests/test-inspector.py
node web/tests/test-build-provenance.mjs
- name: Install browser build dependencies
run: |
Expand All @@ -45,6 +48,10 @@ jobs:
path: specter-diy
submodules: recursive
persist-credentials: false
- name: Write and verify browser freeze manifest
run: |
python3 web/browser/write-browser-manifest.py "$GITHUB_WORKSPACE/specter-diy"
python3 web/tests/test-browser-manifest.py "$GITHUB_WORKSPACE/specter-diy"
- name: Install Emscripten 3.1.74
run: |
git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$RUNNER_TEMP/emsdk"
Expand Down
550 changes: 550 additions & 0 deletions .github/workflows/preview.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .preview-config/emsdk-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3d6d8ee910466516a53e665b86458faa81dae9ba
1 change: 1 addition & 0 deletions .preview-config/virtual-host-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3cf3ecd58a97da0f2cc4b7586ca33abf02f68372
36 changes: 21 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
# Specter DIY web simulator
# Specter DIY Web Simulator

Browser simulator tooling for Specter DIY. It provides the WebAssembly build,
simulated smartcard/SD-card/USB runtime, browser shell, provenance checks,
and tests used by
[`cryptoadvance/specter-diy`](https://github.com/cryptoadvance/specter-diy).
This repository owns the browser simulator and the complete pull request preview
service for Specter DIY. It builds the requested exact Specter source revision,
builds matching firmware, runs firmware and browser tests, validates the
resulting artifacts, hosts PR previews on this repository's GitHub Pages site,
and keeps preview status and lifecycle state.

The firmware repository resolves simulator `main` once per build, checks out
that exact commit in its read-only build jobs, and runs the simulator scripts
from that checkout. The exact Specter source and simulator commits are recorded
in every browser build for provenance.
Specter DIY only dispatches PR metadata to this paired repository, polls the
public status JSON, and updates one local PR comment. It does not build or host
the browser preview.

See [the simulator workflow and release notes](docs/browser-simulator.md) for
the repository contract and migration details.
Start with [the manual build and setup guide](docs/browser-simulator.md). It
covers local builds, paired forks, the narrow dispatch token, Pages setup, and
the build/publisher security boundary.

The official browser build and pull-request previews are published by the
firmware repository at <https://cryptoadvance.github.io/specter-diy/>. To
connect a running simulator to wallet software, use the
The simulator runs the actual `src/` application from
[`cryptoadvance/specter-diy`](https://github.com/cryptoadvance/specter-diy);
this repository contains the browser shell, device shims, and build/test
tooling, not another wallet implementation. For desktop integration use the
[`cryptoadvance/specter-virtual-host`](https://github.com/cryptoadvance/specter-virtual-host)
local bridge and its [latest release](https://github.com/cryptoadvance/specter-virtual-host/releases/latest).
bridge at the immutable revision documented in `.preview-config/`.

**Experimental development build. Never enter a real seed phrase or use real
funds.** A PR preview contains code produced from an untrusted PR and executes
in the visitor's browser.
392 changes: 237 additions & 155 deletions docs/browser-simulator.md

Large diffs are not rendered by default.

12 changes: 1 addition & 11 deletions web/browser/build-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,7 @@ test "$(emcc --version | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
}

python3 "$ROOT/browser/patch-source.py" "$SPECTER_SRC"
cat > "$SPECTER_SRC/browser.manifest.py" <<'EOF'
import os
freeze('f469-disco/usermods/udisplay_f469/display_unixport')
# Newer boards curate embit's src/ layout and omit CPython-only examples/tests.
# Older board revisions had flat common libraries and no curated manifest.
if os.path.isfile('f469-disco/manifests/common.py'):
include('f469-disco/manifests/common.py')
else:
freeze('f469-disco/libs/common')
freeze('src')
EOF
python3 "$ROOT/browser/write-browser-manifest.py" "$SPECTER_SRC"
python3 "$ROOT/tests/test-browser-manifest.py" "$SPECTER_SRC"
make -C "$SPECTER_SRC/f469-disco/micropython/mpy-cross" -j4 \
CFLAGS_EXTRA="-Wno-dangling-pointer -Wno-enum-int-mismatch"
Expand Down
21 changes: 14 additions & 7 deletions web/browser/replace_glue.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
#!/usr/bin/env python3
"""Replace PR-built Emscripten JavaScript with glue from trusted source."""
"""Compose trusted Emscripten JavaScript with untrusted preview data files."""
from hashlib import sha256
from pathlib import Path
import json
import re
import sys


def replace_glue(root: Path, trusted_js: Path):
def replace_glue(root: Path, trusted_js: Path, runtime_provenance: dict | None = None):
root = root.resolve()
if trusted_js.is_symlink() or not trusted_js.is_file():
raise ValueError("Trusted Emscripten JavaScript artifact missing")
Expand All @@ -30,17 +30,24 @@ def replace_glue(root: Path, trusted_js: Path):
raise ValueError("Build manifest missing")
manifest = json.loads(manifest_path.read_text())
artifacts = manifest.get("artifacts")
names = ("micropython.js", "micropython.wasm", "micropython.data")
if not isinstance(artifacts, dict) or set(artifacts) != set(names):
data_names = ("micropython.wasm", "micropython.data")
if not isinstance(artifacts, dict) or set(artifacts) != set(data_names):
raise ValueError("Unexpected build artifacts")
for name in names:
for name in data_names:
path = build / name
if path.is_symlink() or not path.is_file():
raise ValueError(f"Invalid build artifact: {name}")
(build / "micropython.js").write_bytes(js)
for name in names:
js_path = build / "micropython.js"
if js_path.exists() or js_path.is_symlink():
raise ValueError("Untrusted JavaScript was included in the browser payload")
js_path.write_bytes(js)
artifacts["micropython.js"] = {"bytes": len(js), "sha256": sha256(js).hexdigest()}
names = ("micropython.js", *data_names)
for name in data_names:
data = (build / name).read_bytes()
artifacts[name] = {"bytes": len(data), "sha256": sha256(data).hexdigest()}
if runtime_provenance is not None:
manifest["trusted_runtime"] = runtime_provenance
artifact_set = sha256("".join(artifacts[name]["sha256"] for name in sorted(names)).encode()).hexdigest()
manifest["artifact_set_sha256"] = artifact_set
pointer["version"] = artifact_set[:16]
Expand Down
62 changes: 48 additions & 14 deletions web/browser/site.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ let inspectorPending = null;
let inspectorBaseline = null;
let inspectorFileRequest = 0;
let inspectorGeneration = -1;
const virtualHostDetails = $('#virtual-host');
const virtualHostStatus = $('#virtual-host-status');
const virtualHostStatusText = $('#virtual-host-status-text');
const virtualHostPreviewNote = $('#virtual-host-preview-note');
const isolatedPreviewRoute = /(?:^|\/)pr\/\d+\/$/.test(siteRoot.pathname);
let virtualHostSocket;
let virtualHostRetryTimer;
let virtualHostClientId = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
Expand Down Expand Up @@ -252,26 +257,50 @@ function setStatus(message, running = false) {
status.textContent = message;
dot.classList.toggle('on', running);
}
function updateVirtualHostStatus() {
function updateVirtualHostStatus(message) {
const bridge = $('#virtual-host-bridge-status');
const wallet = $('#virtual-host-wallet-status');
if (!bridge || !wallet) return;
bridge.textContent = virtualHostRunning ? 'Running' : 'Stopped';
wallet.textContent = virtualHostWalletConnected ? 'Connected' : 'Waiting';
wallet.classList.toggle('waiting', !virtualHostWalletConnected);
if (bridge && wallet) {
bridge.textContent = virtualHostRunning ? 'Running' : 'Stopped';
wallet.textContent = virtualHostWalletConnected ? 'Connected' : 'Waiting';
wallet.classList.toggle('waiting', !virtualHostWalletConnected);
}
if (!virtualHostStatus || !virtualHostStatusText) return;
const connected = virtualHostRunning && virtualHostWalletConnected;
virtualHostStatus.classList.toggle('connected', connected);
virtualHostStatus.classList.toggle('waiting', !connected);
if (message) {
virtualHostStatusText.textContent = message;
} else if (isolatedPreviewRoute) {
virtualHostStatusText.textContent = 'Local Virtual Host connections are disabled on isolated pull-request previews.';
} else if (!virtualHostRunning) {
virtualHostStatusText.textContent = 'Virtual Host not detected — run it on this computer to connect.';
} else if (!virtualHostWalletConnected) {
virtualHostStatusText.textContent = 'Virtual Host is running — waiting for Specter Desktop.';
} else {
virtualHostStatusText.textContent = 'Connected to Specter Desktop.';
}
}
function scheduleVirtualHostRetry() {
clearTimeout(virtualHostRetryTimer);
if (isolatedPreviewRoute || (!virtualHostDetails?.open && params.get('virtual-host') !== '1')) return;
virtualHostRetryTimer = setTimeout(connectVirtualHost, 2000);
}
function connectVirtualHost() {
if (!$('#virtual-host') || params.get('virtual-host') !== '1' ||
if (isolatedPreviewRoute) {
updateVirtualHostStatus();
return;
}
if (!virtualHostDetails || (params.get('virtual-host') !== '1' && !virtualHostDetails.open) ||
virtualHostSocket?.readyState === WebSocket.OPEN || virtualHostSocket?.readyState === WebSocket.CONNECTING) return;
const query = `?client=${encodeURIComponent(virtualHostClientId)}`;
const localPage = location.hostname === '127.0.0.1' && location.port === '8788';
let socket;
try {
socket = new WebSocket(`${localPage ? `ws://${location.host}` : 'ws://127.0.0.1:8788'}/bridge${query}`);
} catch (error) {
log(`Virtual Host unavailable: ${error.message}`);
clearTimeout(virtualHostRetryTimer);
virtualHostRetryTimer = setTimeout(connectVirtualHost, 2000);
updateVirtualHostStatus(`Virtual Host unavailable: ${error.message}`);
scheduleVirtualHostRetry();
return;
}
virtualHostSocket = socket;
Expand Down Expand Up @@ -302,10 +331,7 @@ function connectVirtualHost() {
virtualHostRunning = false;
virtualHostWalletConnected = false;
updateVirtualHostStatus();
if (event.code !== 4001 && params.get('virtual-host') === '1') {
clearTimeout(virtualHostRetryTimer);
virtualHostRetryTimer = setTimeout(connectVirtualHost, 2000);
}
if (event.code !== 4001) scheduleVirtualHostRetry();
};
socket.onerror = () => { /* onclose updates the status and schedules a retry */ };
}
Expand All @@ -314,6 +340,14 @@ addEventListener('specter-virtual-host-send', event => {
const bytes = event.detail;
if (bytes instanceof ArrayBuffer || ArrayBuffer.isView(bytes)) virtualHostSocket.send(bytes);
});
if (virtualHostDetails) {
virtualHostDetails.addEventListener('toggle', () => {
if (virtualHostDetails.open) connectVirtualHost();
else if (params.get('virtual-host') !== '1') clearTimeout(virtualHostRetryTimer);
});
if (virtualHostPreviewNote) virtualHostPreviewNote.hidden = !isolatedPreviewRoute;
updateVirtualHostStatus();
}
function clearStartupTimer() {
if (startupTimer !== undefined) clearTimeout(startupTimer);
startupTimer = undefined;
Expand Down Expand Up @@ -1191,7 +1225,7 @@ function updateBuildMetadata(manifest) {

try {
if (params.get('virtual-host') === '1' && !embedded && !gallery) {
$('#virtual-host').hidden = false;
virtualHostDetails.open = true;
connectVirtualHost();
}
stateFiles = await awaitPeripherals();
Expand Down
33 changes: 33 additions & 0 deletions web/browser/write-browser-manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Write the trusted frozen-module manifest into a Specter source checkout."""
from pathlib import Path
import sys


MANIFEST = '''import os
freeze('f469-disco/usermods/udisplay_f469/display_unixport')
# Newer boards curate embit's src/ layout and omit CPython-only examples/tests.
# Older board revisions had flat common libraries and no curated manifest.
if os.path.isfile('f469-disco/manifests/common.py'):
include('f469-disco/manifests/common.py')
else:
freeze('f469-disco/libs/common')
freeze('src')
'''


def write_manifest(source: Path) -> Path:
source = source.resolve()
if not source.is_dir():
raise ValueError("Specter source directory does not exist")
manifest = source / "browser.manifest.py"
if manifest.is_symlink():
raise ValueError("Browser manifest path must not be a symlink")
manifest.write_text(MANIFEST, encoding="utf-8")
return manifest


if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("Usage: write-browser-manifest.py SPECTER_SOURCE_DIR")
write_manifest(Path(sys.argv[1]))
Loading
Loading