User-reported install fixes:
- Branding: every user-facing 'SSO Manager' string now says 'Theta Directory'
(agent logs, CLI usage, agent.yml.example, installer wizard).
- Wizard page: the URL/join-key text boxes were never shown. The layout used
Surface.Width (0 at wizard init) instead of SurfaceWidth and combined
WordWrap with AutoSize (mutually exclusive in VCL). Rewritten with the
canonical Inno pattern (SurfaceWidth + ScaleY + explicit label height).
- No console window after install: the tray and helper now build as
GUI-subsystem binaries (-H=windowsgui) in build_all.sh and
scripts/setup-build-env.ps1. The agent stays a console app for foreground
debugging (as a service it never shows a console).
- The daemon never came up after install: install-service now starts the
service immediately, so the tray IPC socket exists right away and the tray
connects instead of logging 'actively refused' until a reboot.
Verified: go build/vet/test green; tray+helper PE subsystem = GUI (2), agent =
console (3); installer compiles; tray runs silently.
Per decision: no binaries committed to the repo; everything is built on GitHub
Actions and hosted as release artifacts (releases/latest/download/<artifact>).
- .github/workflows/release.yml: matrix builds the agent for
linux(amd64/arm64/armv7), windows(amd64/arm64), darwin(amd64/arm64); tray for
linux/windows; helper for windows; the fully-offline Inno setup.exe compiles on
a windows runner via scripts/setup-build-env.ps1 -SkipGo -Build -CI. The
publish job merges everything, writes SHA256SUMS, optionally signs with Azure
Trusted Signing (secret-gated), and attaches to the tag's release.
- Replaces build-windows.yml (removed) — one release pipeline for all platforms.
- Untracks the committed dist binaries (they stay gitignored for local dev and
are produced by CI now).
- DESIGN-WINDOWS.md §9 updated: consumers (install.sh, the SSO modal) download
from GitHub release artifacts; SSO may mirror them into /resources for air-gap.
fyne.io/systray requires .ico content on Windows, and LoadImage cannot read
PNG-in-ICO. The embedded icons are PNG, so SetIcon always failed and no tray
icon ever appeared. pngToIco decodes the PNG and re-encodes classic BMP
(XOR + AND mask) entries at 16/32/48px — the format LoadImage has always
supported. Verified: the 'unable to set icon' error is gone and the tray
process stays up.
feat(installer): wizard page, Start Menu icons, silent install params
- Wizard page asks for the SSO Manager URL + join key, with an 'Open SSO
install-agent page' button (ShellExec); values feed agent.yml
- /SERVER_URL /JOIN_KEY /AUTH_TOKEN /PUBLIC_KEY and /B64_CONFIG (base64 of a
full agent.yml, decoded by an inline B64Decode) drive the same result in
/SILENT mode, so the SSO's Install Agent modal can emit one Windows command
- [Icons]: Theta Agent Tray / Open Agent Config / Uninstall in Start Menu
- WireGuard client launches its UI at the end of the MSI; taskkill after the
msiexec step closes it (the tunnel is agent-managed)
- tray starts right after install (not just at next logon)
- Inno 7 fixes: controls parent to Page.Surface, SaveStringsToUTF8FileWithoutBOM
Adds pngToIco structure tests (valid ICO dir + 32bpp DIBs) and a platform
passthrough test for toWindowsIcon.
Rebuilds all tracked dist binaries + the setup.exe.
The Windows CI/CD story is now one idempotent script that both local dev and
GitHub Actions run, so they cannot drift.
scripts/setup-build-env.ps1:
- idempotent: skips anything already present/valid; safe to re-run (verified
no-op on second run)
- Go toolchain: pinned 1.22.2, user-space zip extract, no admin; accepts >= 1.22
- Inno Setup: pinned 7.0.2 from jrsoftware GitHub release, per-user install
(/CURRENTUSER, no admin), sha256-verified installer
- vendor assets: fetches WireGuard MSI, VC++ redist, OpenCredential CP into
installer/windows/vendor/ and verifies each against the pinned manifest;
writes .sha256 sidecars
- -Build: builds agent/tray/helper for windows amd64+arm64, runs go test, and
compiles the installer; -CI: fail loudly for workflows
installer/windows/vendor-manifest.json: pinned urls + sha256 for all three
third-party assets and the toolchain; nothing large is committed to git
(installer/windows/vendor/ is gitignored).
installer/windows/installer.iss: bundle + silently install the real
OpenCredential installer (Inno-built -> /VERYSILENT) and read /SERVER_URL and
/JOIN_KEY via {param:...}; validated by compiling with Inno Setup 7.0.2.
.github/workflows/build-windows.yml now delegates the entire build to the script
(setup-go + setup-build-env.ps1 -SkipGo -Build -CI), then hashes, optionally
signs with Azure Trusted Signing, attaches to the release, and publishes to the
SSO resource tree.
Built locally: dist/theta-agent-2.1.0-windows-amd64-setup.exe (63MB fully
offline bundle) plus windows amd64/arm64 agent, tray, and helper, all verified.
Second milestone of the Windows parity work (DESIGN-WINDOWS.md §13).
WireGuard mesh client (signed, WSS-delivered):
- wireguard_apply / wireguard_remove commands: Ed25519-verified, gated on a new
wireguard capability. Linux applies via wg-quick up/down; Windows installs the
peer config as a WireGuardTunnel service via wireguard.exe
(/installtunnelservice, /uninstalltunnelservice)
- state polling in the home monitor drives the blue tray icon and auto-VPN:
connect when away from home + auto_vpn, disconnect on return (2m cooldown)
- tray VPN toggle and the auto-VPN checkbox are now live; the preference
persists to agent.yml (PersistAutoVPN)
IAM on Windows (iam_windows.go):
- allowed_login_groups -> net localgroup; ssh_keys -> per-profile
authorized_keys + %ProgramData%\ssh\administrators_authorized_keys;
revoke_users -> helper logs off all of the user's WTS sessions;
sudo_rules logged as no direct equivalent
Tray enrichment:
- Open Config (opens agent.yml), Clear enrollment (re-enroll) menu items
- set_auto_vpn persists; vpn_connect/vpn_disconnect/reinit/open_config commands
handled by the daemon (tray_server.go)
Packaging & release:
- installer/windows/installer.iss: fully-offline Inno Setup bundle (agent,
tray, helper, vendor-signed WireGuard MSI, OpenCredential CP, VC++ redist),
/SILENT /SERVER_URL /JOIN_KEY parameters, SYSTEM service + HKLM Run tray
autostart, Users-writable %ProgramData%\Theta42 for the IPC socket
- .github/workflows/build-windows.yml: build + test, pinned vendor downloads,
ISCC compile, Azure Trusted Signing (OIDC), SHA256SUMS, GH release attach,
optional SSO resource publish
- agent.yml.example documents auto_vpn, wireguard, service_name,
desktop_helper, public_ip_detect
Tests:
- wireguard_apply/remove dispatch (allowed + capability-denied), PersistAutoVPN,
ClearEnrollment; dispatch tests pin linuxPlatformOps with a temp WireGuard conf
- end-to-end verified against a local mock SSO on Windows: join-key enrollment
(token persisted, join key blanked), discovery/telemetry pushed, signed
arbitrary_bash verified + executed via powershell -EncodedCommand; tray IPC
socket binds %ProgramData%\Theta42; LDAP byte-pump binds 127.0.0.1:389;
helper update swap verified
Rebuilds all tracked dist binaries (v2.1.0).
First Windows parity milestone (DESIGN-WINDOWS.md §13 build order item 1).
- Add a PlatformOps abstraction so command dispatch is OS-neutral:
- linuxPlatformOps keeps today's systemctl/journalctl/bash behavior (deliberately
untagged so shared dispatch tests run on Windows CI)
- windowsPlatformOps maps reboot/shutdown to shutdown.exe, service control to
sc.exe (stop+start for restart), fetch_logs to Get-WinEvent, arbitrary_bash to
powershell -EncodedCommand (byte-exact under arbitrary quoting), and declines
configure_ldap (Windows logon goes through OpenCredential)
- Run theta-agent as a Windows service (x/sys/windows/svc): SYSTEM auto-start,
SCM stop/shutdown handling; CLI install-service/remove-service via svc/mgr
- Add theta-agent-helper (session-0 companion): lock/display_off/logout via
user32/wtsapi32, and staged self-update (wait for service stop, swap the
locked exe, sc start)
- Self-update becomes platform-aware: Linux renames over the running binary;
Windows stages .new and hands the swap to the helper (running exe is locked)
- Platform paths: agent.yml and tray.sock under %ProgramData%\Theta42 (the
service runs as SYSTEM while the tray runs as the user, so the per-user temp
dir no longer works for tray IPC); LDAP byte-pump falls back to TCP loopback
- config: service_name, desktop_helper, public_ip_detect (air-gap: skips
external public-IP lookups in telemetry + home monitor), wireguard block
- cli: platform-aware config path + self-update artifact name + service restart
- tests: dispatch tests pin linuxPlatformOps; 0600 mode assertions gated to
POSIX so the suite is green on Windows
Rebuilds all tracked dist binaries (v2.1.0).
Covers full Windows parity for the theta-agent: remote operations executors,
Windows service layout, WireGuard mesh client (WS-pushed config), LDAP directory
logins via a vendored OpenCredential credential provider, enriched tray,
fully-offline Inno installer, and GitHub Actions + Azure Trusted Signing build
with the SSO holding all release resources. Optimized for air-gapped deployment.
The tray IPC socket was hardcoded to /run/theta/tray.sock and /tmp/theta-tray.sock,
which cannot be bound on Windows (they resolve to C:\run\... and C:\tmp\... and need
admin rights). The daemon now binds a Unix socket under the per-user temp dir on
Windows, and the tray companion dials the same path.
The tray also exited immediately on Windows because it checked DISPLAY/WAYLAND_DISPLAY,
which are never set there. That graphical-session guard is now Windows-only; the tray
always runs on Windows.
- tray_icons.go / cmd/theta-agent-tray: Renders iconic Theta 42 logo in status colors:
- 🔴 Red: Not connected to directory
- 🟡 Yellow: Connected to directory, but not on home LAN
- 🟢 Green: Connected to directory on home LAN
- 🔵 Blue: Connected to directory with active WireGuard tunnel
- home_detect.go: Compares agent public IP with home site public IP
- tray_server.go / tray_ipc.go: Unix socket IPC daemon server (/run/theta/tray.sock or /tmp/theta-tray.sock)
- cmd/theta-agent-tray: Desktop GUI binary with system tray menu (Auto-connect toggle, Connect/Disconnect VPN)
- Auto-detects DISPLAY / WAYLAND_DISPLAY environment variables
See CHANGELOG.md for the full breakdown. Summary:
- ldap_tunnel.go: serves a local unix socket for SSSD/PAM and relays raw
bytes to the SSO over the existing WSS channel (ldap_tunnel messages);
the agent never parses LDAP (DESIGN.md §4). Adds safeWriter to
serialize WebSocket writes now that telemetry, heartbeat, the LDAP
tunnel, and command responses all share one connection.
- secrets.go: renders local templates ({{ bao "path#key" }} placeholders)
by fetching node-scoped values from the SSO and writing the target
atomically at 0600, on a signed render_secrets command (DESIGN.md §5).
demo/ has minimal bash + Node consumers of the rendered file.
- iam.go: applies signed node IAM pushes -- sudoers.d rules (visudo -c
validated), SSH AuthorizedKeysCommand keys, /etc/security/access.conf,
and revocation via sss_cache -E + pkill -u (DESIGN.md §6).
- Capability reporting: the agent's enabled capabilities ride along in
its discovery frame so the SSO can show them in the Directory.
- DESIGN.md: the v2 protocol design this implements.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
theta-suite's setup.sh installs the committed theta-agent-linux-amd64
rather than building from source, so a stale binary means the fix in this
repo never reaches the host.
The v1.5.0 binary predated join-key support: setup.sh would write a
join_key into agent.yml that the running agent did not understand, and it
would have looped on "close 4001: Unauthorized" -- the same trap the
v1.3.0 heartbeat fix hit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Installing the agent with one key is now all it takes to add a host.
New `join_key` config field, presented while auth_token is empty. The SSO
exchanges it for this agent's own token and the public key it must pin,
both delivered in the config frame; the agent persists them and blanks
the join key. Nothing has to be copied between two machines by hand.
PersistEnrollment rewrites only the credential lines -- line-based rather
than a YAML round-trip -- so operator comments, the capability matrix and
formatting survive. It re-reads afterwards so the credential is live
without a restart, and keeps the file 0600.
The connect URL carries ?hostname= so a self-enrolling host is named
after itself, and the agent refuses to connect at all (with a long
back-off) when it has no credential rather than presenting an empty one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements protocol v1.2.0.
verifySignature() returned true when no public_key was configured,
logging "skipping signature verification". The SSO installer never wrote
a public_key, so a default install executed reboot, service_restart,
configure_ldap, arbitrary_bash and update_binary UNVERIFIED from anything
that could reach its socket. An agent that cannot verify now refuses.
Canonicalization also disagreed with the server. Go's encoding/json
escapes <, > and & by default; JSON.stringify does not. Any payload
containing them hashed differently on each side and failed verification
-- for arbitrary_bash that is most real scripts (`>` redirection, `&&`).
Now uses json.Encoder with SetEscapeHTML(false), trailing newline
trimmed.
The SSO now rejects tokens it did not issue. Handles its close codes
(4001/4002/4003/4004) and backs off 5 minutes on an enrollment failure
instead of retrying every 5s forever. The connect log no longer prints
the URL, which carried ?token=.
install.sh gains --public-key and warns when none is configured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The prebuilt theta-agent-linux-amd64 was built before the v1.3.0 heartbeat_ack
fix, so the installed agent still logged 'Unknown command type: heartbeat_ack'.
Rebuild it with the fix; add a test asserting heartbeat_ack is silently ignored
(no response, no command, no log).
The server replies to the agent's own heartbeat with heartbeat_ack; the agent
had no case for it, so it fell through to the unknown-command handler, logged
'Unknown command type: heartbeat_ack' every minute, and answered with a spurious
error response. heartbeats are fire-and-forget acks — nothing to run, nothing to
reply.