Repository navigation
ghost.js8 0.1.0: native JS8Call via KiwiSDR, GhostNet autopilot, ops console UI, amd64 + arm64 - #1
Merged
Merged
Conversation
…3.0 license Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
…and pytest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
…int, prettier and vitest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
…Renovate config Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
…ap-source and discard sink Ubuntu 24.04 by digest; AppImage downloaded, SHA-256 verified and extracted at build time. Xvfb, PulseAudio and JS8Call run under supervisord; a FATAL critical process exits the container with code 70. JS8Call.ini is rendered from the environment on every start: stable Pulse device IDs, monitoring on, UDP API to localhost, decode filter 100-3000 Hz, and spotting/transmit forced off. Healthcheck requires JS8Call to hold an uncorked capture stream on ghost_rx_in. Verified locally: the upstream A_1_4.wav fixture played into ghost_rx decodes 'K0OG: KN4CRD SNR +02' over the UDP API on both replays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
…h job Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
…nd fake Kiwi Receiver interface with typed errors (rejections, unreachable, timeout, pairing, malformed frames, unsupported audio formats) and an injectable clock. Pure framing functions parse SND/W/F/MSG, normalize big-endian PCM to s16le, honor the little-endian flag without double-swapping, and reject compressed, stereo, IQ and non-12 kHz audio. KiwiReceiver opens both streams under one session id, sends only per-channel settings, and keeps alive. ghostjs8.sim.fake_kiwi serves real SND/W/F framing with UTC-aligned WAV playback, synthetic waterfall rows, pairing enforcement and fault injection. 64 tests (unit + localhost integration). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
The agent runs inside the decoder container: it accepts one bridge WebSocket, pipes PCM into the ghost_rx null sink through a bounded pacat writer, owns JS8Call's localhost UDP API (learning the reply port from incoming datagrams), forwards RX.ACTIVITY/RX.DIRECTED/RX.CALL_ACTIVITY verbatim, polls call activity (RX.SPOT is suppressed while spotting is off), and reports health facts including whether JS8Call holds an uncorked capture stream. Only read-only requests can be sent; TX.* is impossible by construction. The bridge-side adapter normalizes JS8Call messages (directed traffic shown as 'FROM: ...' with the EOM marker stripped) into decode and spot events. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
Pydantic contract v0 (hello, decode) with protocol version; bounded fan-out hub with per-session client limit; station wiring receiver -> decoder -> browsers with jittered exponential backoff on both sides; FastAPI /ws with Origin allowlist and /healthz; env-based settings with optional boot-time receiver for persistent listening; python -m ghostjs8 entry point. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
Fetches A_1_4.wav from JS8Call-improved at 4c592bd9 and verifies its SHA-256, serves it from fake-kiwi with real SND framing aligned to UTC 15 s boundaries (twice), runs bridge + decoder (now with the supervised decoder-agent) on an isolated network, and asserts 'K0OG: KN4CRD SNR +02' arrives on the browser-facing WebSocket. Passes locally. Bridge image on python:3.12-slim by digest with uv-locked hashed dependencies; CI gains an acceptance job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
…cript Pydantic server/client unions (hello, history, session, health, decode, station, receiver_status, error, pong; hello, subscribe, tune, select_receiver, disconnect_receiver, ping) plus the receiver directory HTTP type. make contract generates contract/schema.json and web/src/lib/protocol/generated.ts via json-schema-to-typescript; CI fails on drift. Binary audio/waterfall layouts in contract/binary.py and docs/protocol.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
Clock-injected tracker for bridge, decoder process/API/capture, receiver, audio and waterfall freshness, with since/last_seen per component and a last-decode time set only by real decodes. Covers decoder crash while the bridge stays up, agent link loss, stale reports and heartbeats. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
Directory parses the public KiwiSDR list (rx.linkfanel.net), caches 15 min, serves stale on error without hammering. Grids are normalized and only valid 4/6/8-character locators are ever passed on. SQLite (WAL) keeps decodes and heard stations with retention pruning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
… logs Station drives the receiver lifecycle with a reconnect policy (no retry after auth/redirect or unsupported formats; at least 60 s after full/busy), handles tune/select/disconnect from browsers, fans out audio and rate-capped waterfall frames only to subscribed clients, debounces ADC overload, persists decodes/stations, sends history on join, and can release the receiver when nobody is watching. The API validates receiver addresses (no loopback, link-local or service names), rate-limits control messages, and exposes /readyz and /api/receivers. Logs are structured JSON with session ids. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
Compose's bake builder refuses to read /dev/null as a build secret without an extra --allow grant, which failed the acceptance job in CI. Default the extra_ca secret to an empty placeholder inside the project and build without bake so local and CI builds behave the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
Binary frame decoding, a reconnecting bridge client that validates server messages and releases its socket on stop, a bounded PCM ring buffer with drop/resync semantics, waterfall colormaps (spectre default, viridis, inferno, grey) and peak-preserving row blitting, Maidenhead validation mirroring the bridge, JS8Call's default band presets, a pure state reducer that collapses activity+directed records of one transmission, and a situation model that tells band-quiet / receiver-full / ADC-overload / decoder-offline apart. 42 Vitest tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
Glass panels with redaction-bar headers, a classification strip with UTC clock and link state, and design tokens as the single source for colour, blur, radii, spacing, type and motion (honours reduced motion and reduced transparency, solid fallback without backdrop-filter). Self-hosted JetBrains Mono and Space Grotesk, SVG mark and favicon. Panels: receiver directory search + manual host:port, JS8 band presets and passband tuning, AudioWorklet monitor with bounded buffering, DPR-aware scrolling waterfall with passband overlay, decoded-traffic timeline with a throttled live region, heard stations, and a MapLibre map of stations with valid grids over bundled Natural Earth outlines (no tile requests). Responsive: three columns, two on tablets, tabs on phones. No transmit control exists anywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
fake-agent speaks the real agent protocol and emits scripted JS8 traffic; fake-kiwi now honours the requested waterfall view and paints JS8-like tones. tools/dev-stack.sh runs fake Kiwi + fake agent + real bridge without Docker. Playwright (desktop + phone) checks link, traffic, stations, waterfall pixels, map grids, audio start/stop, an axe scan, and that no transmit control exists in any view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
web: Vite build served by nginx-unprivileged (digest-pinned) with a strict CSP, security headers, a same-origin /ws proxy with upgrade headers and long timeouts, and an explicit JavaScript MIME type for .mjs module workers. docker-compose.yml publishes only the web port (bound to 127.0.0.1 by default), keeps the decoder on an internal network with no egress, and persists the bridge's SQLite store on a volume. CI builds the web image and runs the Playwright suite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
operations.md covers deployment behind Caddy / Nginx Proxy Manager, the health model, what each headline means, troubleshooting, KiwiSDR etiquette, the manual real-receiver validation checklist, and the transmit extension point. SECURITY-UPDATES.md lists every pin, the Renovate setup, the JS8Call update procedure (gated on make acceptance) and hardening in place. architecture.md records where 0.1.0 deviates from the plan (basemap, one station per bridge, no hold lock yet, RX.CALL_ACTIVITY as station source). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
Encodes the GhostNet v1.5 JS8Call plan (Thursday nets on 7.107 MHz, Saturday data bridges on 14.107 / 3.575 / 7.107 MHz) per region. When enabled, the autopilot tunes into each window with a 10 minute pre-roll on the nearest free public KiwiSDR to the operator's home grid (benching receivers that reject or fail to connect), records the window, and parks on 7.107 MHz between nets. A viewer taking manual control pauses it until the window ends. Each net is recorded as its own traffic log, a FLAC of the receiver audio (soundfile/libsndfile) and a waterfall PNG (one row per second, -500..3500 Hz, spectre palette, stdlib encoder), with its own retention. New ghostnet status message, /api/nets endpoints with strict id validation, opt-in settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
… output in lint Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
- GhostNet panel: autopilot mode, current/next window with countdown (UTC + local), recording indicator, receiver choice reason, recorded nets - Net log view (#/net/<id>): waterfall image with time/offset axes, FLAC playback with click-to-seek, stations and traffic tagged FLASH/GN/DIR - Live @GSTFLASH banner (role=alert) with acknowledge - seed-net command and dev-stack GHOSTNET=1/SEED_NET=1 for UI work and e2e - Timeline list is keyboard-focusable when it scrolls (axe) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
…notes - docs/ghostnet.md: schedule, configuration, receiver choice, override semantics, recording layout and API, UI, dev workflow - .env.example, README, operations, CHANGELOG, AGENTS gotchas - Net log: don't clip the +0m time tick Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
… layout - Tokens: ink base, aurora backdrop colours, aqua→violet accent gradient, mint signal, softer warn/alert; larger radii; lighter, more translucent glass - Slow-drifting aurora behind the glass (off for reduced motion/transparency); film grain kept, scanlines dropped - Panels: frosted card with lit gradient edge, sentence-case titles - Sticky glass top bar replaces the classification banner + masthead - Hero strip: gradient frequency readout, situation, decodes/stations/last decode - Pill buttons with gradient primary/pressed states; glass inputs; chips - Traffic rows: category rail, sender callsign highlighted, chip tags - Waterfall: inset canvas, retuned "spectre" aurora colormap (bridge PNG palette mirrored), restyled map, GhostNet panel, net log and flash alert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
… aurora - Canvas ASCII field behind the glass: drifting ionospheric static with per-cell flicker, radio-wave rings ((( ~ ))) that ripple out with the sender's callsign on each new decode, a soft pulse on every UTC-aligned 15 s JS8 slot, and an occasional ASCII ghost - Deep solid gradient base (ink -> midnight indigo -> plum); aurora removed - Top-bar toggle for a still gradient (remembered per viewer); reduced motion draws a single frame, reduced transparency hides the canvas; paused while the tab is hidden; capped at 16 fps - Stronger frost so backdrop glyphs never compete with panel text - Unit tests for the engine's helpers; e2e for the toggle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
- Logo: line-art sheet ghost in military-stencil style (bridges cut into the outline), radio-wave hem, angled slit eyes; favicon to match - Wordmark "ghost.js8_" in Share Tech Mono with a blinking cursor (static under reduced motion); tagline in the same face - --g-font-display token: panel titles (uppercase, tracked), the hero frequency readout and the net-log heading use it - @fontsource/share-tech-mono pinned at an exact version Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
…sets - docs/assets: banner (stencil ghost + wordmark over the signal backdrop), dashboard screenshot, standalone mark - README trimmed to the essentials: what it does, quick start incl. GhostNet, docs index, develop, license - Backdrop callsign labels slightly fainter behind glass Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
…chip - Tagline "JS8 ops console" in the top bar, banner, README and meta; the footer and hero labels drop "receive-only"/"listening post" - The "Receive only" badge becomes an RX mode chip: capability, not brand, tied to the contract's hello.receive_only; a future transmit extension changes the chip, not the name - README: RX mode badge and an "RX today, built to grow" note; the receive-only invariant and its test are unchanged - Refreshed banner and dashboard screenshot Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
… in CI
- Decoder picks the JS8Call AppImage by TARGETARCH (x86_64 | aarch64), each
pinned by its own SHA-256; the AppImage is unpacked with unsquashfs at the
offset after its ELF runtime and never executed during the build
- CI: decoder build+boot and the real-recording acceptance test run on
ubuntu-24.04 and native ubuntu-24.04-arm; web and bridge images build on
both (bridge also proves FLAC writing in a read-only container)
- release.yml: vX.Y.Z tag -> native per-arch builds pushed by digest, merged
into multi-arch manifests on GHCR (ghostjs8-{web,bridge,decoder}:X.Y.Z,
:latest) with provenance and SBOM; actions pinned by SHA
Verified locally: amd64 acceptance passes; arm64 acceptance passes under
qemu emulation (aarch64 JS8Call decodes the upstream recording).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
…t floor, hardening From a pre-0.1.0 security review: - Receivers chosen by viewers or the GhostNet autopilot are resolved at connect time, every address is checked, and the checked IP is dialed (URL keeps the name for Host/SNI). Closes 127.1 / 0x7f000001 tricks, internal DNS names and rebinding. Redirects are refused. The operator's boot receiver stays trusted. - Private receivers are opt-in (GHOSTJS8_ALLOW_PRIVATE_RECEIVERS=false by default); loopback only for the simulated stack. - Viewers see generic "unreachable"/"timed out" details, not raw errors (no port-scanning oracle); address refusals are terminal. - Station-wide 5 s floor between receiver connects; a burst of re-selects connects only the latest choice (invariant 10). - WebSocket Origin: same-origin by default when no allow-list is set, port-aware; nginx forwards $http_host so ports survive. - Kiwi password may not contain whitespace/control characters (it is sent inside "SET auth"); contract regenerated. - nginx security headers included on every location; 16k body limit. - All containers: cap_drop ALL + no-new-privileges (prod and test compose). - IPv6 receiver literals are bracketed in URLs. - Compose image refs take GHOSTJS8_IMAGE_PREFIX / GHOSTJS8_TAG for published images; docs for ARM, LAN receivers and hardening. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5
d3mocide
marked this pull request as ready for review
October 8, 2026 14:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Everything for the 0.1.0 release: the receive-only MVP, the GhostNet autopilot, the redesigned UI, 64-bit ARM support, a multi-arch release pipeline and pre-release security hardening. Design and decisions are in
docs/architecture.md, which also records where the build deviates from the plan.What's in it
unsquashfswithout ever being executed.module-remap-source→ JS8Call, and a discard sink.docs/ghostnet.md):RXmode chip (capability, not brand).@GSTFLASHalert.docker-compose.yml: onlyweb(nginx) publishes a port, on 127.0.0.1 by default. The decoder runs on an internal network with no egress.no-new-privileges.GHOSTJS8_IMAGE_PREFIX,GHOSTJS8_TAG).vX.Y.Ztag builds natively on amd64 and arm64 runners. Images are pushed by digest and merged into multi-arch manifests on GHCR (ghostjs8-{web,bridge,decoder}), with provenance and an SBOM.Security review (pre-0.1.0)
A dedicated review found no high-severity issues. Every medium and low finding is fixed and tested:
127.1/0x7f000001tricks, internal DNS names and DNS rebinding. HTTP redirects are refused.$http_host; this was found by end-to-end testing through nginx.SET auth.Findings along the way
RX.SPOTonly when PSKReporter spotting is on, so the agent pollsRX.GET_CALL_ACTIVITYinstead.RX.DIRECTEDomits the sender and appends♢. PSKReporter and APRS spotting default to on; both are forced off..mjsas octet-stream, which breaks MapLibre's worker undernosniff. Fixed in the template.Verification
make check: ruff, mypy --strict, 248 pytest, eslint, prettier, svelte-check and 53 Vitest.make acceptance:K0OG: KN4CRD SNR +02reaches the browser WebSocket.make e2e: 20 Playwright tests (desktop and phone), including the axe scan, the no-transmit assertion, GhostNet, and the backdrop toggle./, assets,.mjsand/api;127.1,0x7f000001,decoderand private IPs refused as receivers.Not done yet / follow-ups
docs/operations.md).X-Forwarded-Forfrom the reverse proxy.v0.1.0to publish images.🤖 Generated with Claude Code
https://claude.ai/code/session_01GQHMvquKtGGkMTjMwxGrZ5