Skip to content

feat: close the audit backlog - honest update checks, resumable installs, and a trust chain that reaches the apps - #68

Merged
MotherSphere merged 18 commits into
mainfrom
claude/colony-improvements-598c1e
Aug 27, 2026
Merged

feat: close the audit backlog - honest update checks, resumable installs, and a trust chain that reaches the apps#68
MotherSphere merged 18 commits into
mainfrom
claude/colony-improvements-598c1e

Conversation

@MotherSphere

Copy link
Copy Markdown
Member

Colony 0.9.2 had no written TODO: no open issues, no TODO/FIXME in the code,
119 green tests. This is the backlog reconstructed by audit, and the work that
closed it.

An audit across nine dimensions produced 135 findings that survived adversarial
verification (3 were refuted). None were p0 — the core is genuinely well built.
The most damaging one was not a crash:

Colony told users their apps were up to date whenever it could not check.
check_update_available returned Option, conflating "no update" with "the
check could not run", and the handler then replaced the badge map wholesale.
Offline, on a VPN, or simply on a second launch within the hour — a cold boot
costs ~55 of the 60 anonymous GitHub requests — every update badge was cleared
and the all-clear line was written. The correct invariant already existed, one
file over, for the launcher's own check.

What is in here

Failures become visible. The status line rendered on one of four pages;
RUST_LOG=debug was silently overridden and no log file existed anywhere; the
bug template requires colony --version, which did not exist. Toasts never
expired under reduce-motion and grew past clicking range — the accessibility
settings were the ones that silted the UI up.

The install survives reality. The 300 s timeout was a total deadline, so
below ~1.2 Mbit/s no asset could ever finish. Transfers now resume (Range plus
an identity sidecar, bounded retry), because every failure path used to delete
the partial file — a drop at 95% cost the whole download. Replacing a running
binary works on Windows now; Colony already knew the trick and applied it only
to itself.

Remote strings stop being structural. A tag from colony.json was
interpolated into the release URL, and the WHATWG parser collapses .. before
the request is sent — one line of a catalog repo could redirect an install to an
account outside the org. URLs are built from percent-encoded segments now, with
a test that replays the attack.

The trust chain reaches the apps. The release key is a list, so rotation is
expressible at all. App assets are bound to a version and an asset name by the
signed .meta sidecar — opportunistic, then pinned, so the org can adopt it one
repo at a time. colony validate-manifest catches the manifests that parse
perfectly and still leave an app uninstallable.

Platforms stop overpromising. keyring was built without its macOS and
Windows backends, so on those platforms the token was written to an in-memory
mock and lost on every restart while the log said "saved to OS keychain". The
README now says Linux supported, Windows/macOS best-effort, with the gaps named.

Verification

cargo fmt --check clean, cargo clippy --all-targets -- -D warnings clean,
142 tests (up from 119). Resumable downloads are covered end to end against
a Range server that truncates its first response. Every workflow parses; the
five action SHAs added were checked against upstream.

Two audit findings did not survive checking and are deliberately not
"fixed": that the shipped binary contains OpenSSL (cargo tree --target all -i openssl-sys finds nothing), and that the theme counts were merely stale — they
were wrong by exactly one, which a test now pins.

Commits

Squash-merging collapses these; the map is kept here.

  • fix(download): use a read timeout instead of a 300 s total deadline
  • fix(update): never report apps as up to date when the check did not run
  • fix(ui): make failures visible - status line everywhere, logs, and retry
  • fix(security): validate remote strings before they reach URLs and paths
  • feat(github): persist the conditional-request cache across launches
  • feat(download): resume interrupted transfers
  • fix(platform): replace running binaries safely, and stop overpromising Windows/macOS
  • fix(update): report every failure the user triggered
  • fix(github): distinguish a broken manifest from a transient failure
  • docs: correct every path, count and claim the code had outgrown
  • feat(signing): make the key rotatable and bind app assets to their release
  • feat(store): say what is on offer before the user commits to it
  • feat(manifest): validate colony.json, and make browsing tell the truth
  • chore: pay down the debt the audit found, and guard it with tests
  • ci(release): close the unsigned window, smoke-test artefacts, scope secrets
  • docs: point at the one canonical release template instead of a second copy

The ecosystem-side rollout (the .meta sidecar and the signing job in the six app repos) is not in this PR: the canonical template and the migration procedure now live in Project-Colony-Resources.

reqwest's ClientBuilder::timeout is a total deadline covering connect, TLS,
redirects and the entire body stream. At 300 s that made any asset larger than
the link could carry in five minutes impossible to fetch: a 40 MB binary needed
a sustained ~1.2 Mbit/s or it failed every single time, and since nothing is
resumed, each attempt spent a full asset's worth of quota for no retained
progress. The same client backs the launcher self-update, so Colony could not
update itself on such a line either.

read_timeout bounds the only thing worth bounding - a connection that has
stopped delivering bytes. A stalled socket still dies in 60 s; a slow but live
one now runs to completion.
check_update_available returned Option<String>, collapsing 'no update' and
'the check could not run' into the same None. updates_checked then REPLACED
available_updates wholesale and wrote the all-clear status line, so going
offline - or simply hitting the anonymous rate limit on a second launch within
the hour - cleared every update badge and affirmatively told the user their
apps were current. No toast, no log, no way to notice.

The correct invariant already exists one file over: check_launcher_update
returns Result and update/launcher.rs refuses to claim 'up to date' on a failed
check, with a regression test. This gives the app-side check the same contract:

- check_update_available returns Result<Option<String>> ('not installed' stays
  a legitimate Ok(None), a failed tag resolution is now an Err)
- UpdatesChecked carries one outcome per repo, so a partial failure keeps the
  results that did come back
- updates_checked MERGES: a repo whose check failed keeps the badge it had, a
  repo that checked clean loses it, and any failure suppresses the all-clear
  line in favour of a warning naming the count

Also chains the update queue through download_release's two silent early
returns. A repo that left the catalog mid-run (a refresh replaces it wholesale)
stranded every remaining entry, which a later unrelated install would then
silently drain into an unannounced cascade of downloads.
Colony had no channel that reliably reached the user when something failed.
Five parts of the same problem:

status_message had exactly one render site, inside the grid header. The detail
page returns early and settings and the GitHub panel replace the content pane,
so on three of the four pages the status line did not exist - and it was the
sole feedback for "no release for your platform", a failed uninstall, a failed
release-notes fetch and a rate-limited refresh. It is now a footer in
App::view, rendered on every page, which also stops a long raw transport error
from laying the Fill search input out at width zero.

Diagnostics reached nobody either. EnvFilter::from_default_env().add_directive(
INFO) reads as "default to info", but a bare RUST_LOG=debug parses to a
directive that compares Equal to the added one, so add_directive REPLACED the
user's request. There was no log file at all, and a .desktop launch has no
terminal (on Windows, no console). RUST_LOG is now honoured verbatim, and logs
go to the cache dir as well as stderr, truncated per run.

colony --version opened the GUI, while the bug template makes its output a
required field. --version and --help are now answered before the window opens.

Toasts never expired with reduce-motion or animations off: the expiry timer was
gated on animations, so the retain(!is_expired) branch was unreachable and the
stack grew without bound. Since the overlay grows upward from the bottom, the
oldest toasts left clicking range and could never be dismissed - the
accessibility settings were the ones that silted the UI up. The timer is now
always armed and the stack is capped at five.

Finally, a failed catalog fetch left an anonymous user with no way out but
signing in or restarting. Refresh is now offered in the disconnected and error
arms of the GitHub panel and in the grid's empty state - the same anonymous
fetch boot already runs. A refresh the user clicked also toasts on failure now;
the anti-noise rule stays for the boot path.
The most serious of these is the release URL. `tag` comes from colony.json and
was interpolated straight into
https://github.com/{org}/{repo}/releases/download/{tag}/{filename}. reqwest
parses through the WHATWG URL parser, which collapses `..` segments BEFORE the
request is issued, so a tag reading
`v1/../../../../../EvilOrg/EvilRepo/releases/download/v1` shortens the path
into an account outside the org - and Colony then installs and trusts those
bytes. That is reachable from write access to one line of a catalog repo, with
no release-publishing rights anywhere, and it survives a JSON diff review. It
breaks the single containment claim the trust model rests on.

URLs are now built from percent-encoded path segments (build_url), applied to
the release URL, the tagged-release API call and the contents API call that
carries the manifest's icon path. Encoding rather than rejecting, because a
legitimate git tag may contain a slash.

Also in this batch:

- ensure_safe_component was POSIX-shaped. Rust's Windows path parser treats
  "payload:stream" as a single Normal component, so it passed the guard and
  wrote an NTFS alternate data stream; reserved device names (CON, NUL, COM1)
  resolve to devices from any directory; and a trailing dot or space is
  stripped by the filesystem, so the name checked differed from the name
  written. Enforced on every platform, since the catalog is shared.
- .meta and .blockmap join NON_INSTALLABLE_SUFFIXES. Without them, the day an
  ecosystem app adopts the signed sidecar, a documented substring filePattern
  starts matching two assets and fails - which the spec promises cannot happen.
- The staged `<exe>.new` write, the staged .sig/.meta/.meta.sig writes and the
  OAuth token file now use create_new. fs::write follows a symlink planted at
  those predictable names; for <exe>.new that meant the rename afterwards moved
  the SYMLINK over Colony's own binary.
- Buffered sidecar fetches are capped at 64 KiB. A release can publish a
  multi-gigabyte file named foo-linux.sig, which was an OOM on click.
- The install path reads the staged file once and checks the signature and the
  digest against that one buffer, instead of three separate reads of a
  predictable path between verification and install.
- OAuthSession has a hand-written Debug that redacts the token. Message derives
  Debug and carries one, so a future `tracing::debug!(?message)` would have put
  it in the log file.
HTTP_CACHE was a process-local LazyLock that nothing ever serialised, so a cold
boot and a warm boot cost exactly the same number of GitHub requests. Replaying
the org catalog against the live org measures ~55 requests for a full refresh,
against an anonymous budget of 60/h - so opening Colony twice within the hour
rate-limited the second launch, and the user got a stale catalog. GitHub does
not bill a 304, so the map only ever needed to survive the process.

It now loads from <data>/cache/http_etags.json at first use and is written back
after a successful catalog refresh, where it is most complete. The file is
bounded: entries are selected smallest-first so a squeeze drops the handful of
giant READMEs rather than the dozens of small manifest responses that make up
most of the request count, and any single body over 512 KiB is never persisted.

404s are remembered too, for six hours. The catalog deliberately generates
them - several CHANGELOG names, several licence filenames, a second icon path,
and colony.json for every repo in the org including those that will never have
one - and an ETag structurally cannot help there, because a 404 carries none.
That is the difference between comfortably under and comfortably over the
anonymous quota. The TTL is the cost: a repo that adds a CHANGELOG is picked up
on the next window, or immediately via Settings > Clear caches, which now
clears this cache too.
A dropped connection used to throw away every byte received. download_to_file
opened the staging file with create_new (which unlinks first) and every failure
path deleted the partial, so a drop at 95% of a 40 MB asset cost the user 38 MB
and the next click re-paid the full 40. There was no retry either, so 'the
network blipped' was indistinguishable from 'this app cannot be installed'.

Transfers now resume. A partial file survives a transport failure alongside a
small identity sidecar recording the server's ETag and the total length; the
next attempt sends Range and appends. download_with_resume wraps that in three
bounded attempts with backoff, so an ordinary blip is invisible to the user
rather than a failed install.

Resuming means stitching two responses into one file, so identity is checked
rather than assumed. If-Range is sent but NOT trusted: GitHub redirects release
assets to Azure blob storage, which ignores the header and answers 206 to a
stale validator just the same - verified against a real release asset. So a 206
is only accepted when the response's own ETag and the total in Content-Range
still match what the partial file was recorded against; on any disagreement the
partial is discarded and the retry starts clean. Nothing weakens verification:
the signature and digest still run over the completed file.

Covered by a test that stands up a truncating Range server on std::net (no new
dependency) and asserts the stitched file is byte-identical.

Also in this batch:

- Advertised bodies over 4 GiB are refused up front, and the stream aborts if
  one exceeds the ceiling anyway. The whole asset is written to disk before any
  check can run, so a release could otherwise fill the user's partition on its
  own say-so.
- The launcher download stages to <asset>.part and is renamed only once
  complete. It used to write straight to the path apply_launcher_update
  consumes.
- prune_staging() runs at boot and removes orphaned .part/.new/.old files under
  apps/ and update-staging/, plus the launcher's own backup. Cancel was the only
  sweep that existed, so a crash or a closed window leaked the whole partial
  asset with no UI that showed it. The launcher backup is removed by exact path,
  never by sweeping the executable's directory - which may well be /usr/bin.
- The .colony_asset marker is written BEFORE .colony_version. The two writes are
  individually non-atomic, and in the old order a kill between them left a
  filePattern app claiming the new version while the asset marker still named
  the old binary - so Colony reported the new version, Launch ran the old one,
  and no update was offered to correct it. This order makes a torn install
  simply re-offer the update.
…g Windows/macOS

The install path could not update an app the user had left open. A plain
std::fs::rename over a live executable fails on Windows - the image is held with
FILE_SHARE_READ|FILE_SHARE_DELETE, so MoveFileExW cannot delete the destination
- and it failed AFTER downloading and verifying the whole asset, with a raw
'Access is denied' and no hint that closing the app would help. Colony already
knew the parade and applied it to its own self-update; replace_file() factors it
out so both halves work the same way. Uninstall gets the same treatment: a
directory whose binary cannot be deleted has its files renamed aside, and the
boot sweep collects them once the process holding them has exited.

launcher_is_system_managed() was #[cfg(unix)] and matched on /usr and /opt, so a
Windows install under Program Files - not writable unelevated - reported false,
offered the update button, and died on the same rename. It is now behavioural:
probe whether a file can be created next to the executable. One test covers
/usr, /opt, Program Files, a read-only mount and /Applications, with no
per-platform path list to keep in sync.

keyring was built with only the Secret Service backend. keyring 3 has no default
feature, so on Windows and macOS its backend resolved to the in-memory mock backend,
whose set_password returns Ok while storing nothing - and Colony reads that Ok
as success and then deletes its plaintext fallback on purpose. The token was
written nowhere and every restart logged the user out, while the log said
'Token saved to OS keychain'. Both native backends are target-gated, so the
Linux build is unchanged and no openssl enters the tree (verified with
cargo tree --target all).

An app whose asset name carries the version - the case filePattern exists to
serve - left its previous binary behind on every update, invisible to the user
and reclaimed only by an uninstall. SphereCord's AppImage is 166 MB. The
superseded file is now removed once the new one is committed.

The self-update relaunch no longer exits unconditionally. apply_launcher_update
has already committed the swap by then, so discarding the spawn result meant a
binary that would not exec made Colony vanish for good, with no window, no
toast, and no way to know a .old backup exists. A failed spawn now surfaces as
an error naming both paths.

Finally, the platform promise is now honest. The README advertised all three as
'Supported'; Windows and macOS are marked best-effort with the specific gaps
named. The shipped categories.json had a hardcoded 'Windows' and 'Linux'
section, so a Windows user got a permanently empty 'Linux' entry and a macOS
user found /Applications filed under one labelled 'Linux'. Sections take an
optional "platforms" list, the three system sections are gated to their own OS,
and the missing macOS one is added. Its icons are restored too - the shipped
config had regressed every glyph to an empty string while the unreachable
built-in fallback kept the real ones.
A cluster of handlers that changed state, or refused to, without telling anyone.

Logout swallowed the keyring result and reported success unconditionally. On the
next launch load_saved_token restored the session from the credential the user
believed they had revoked. delete_saved_token now reports what actually
happened (treating "nothing stored" as success), and a failure surfaces as an
error naming github.com/settings/applications - matching the care save_token
already took on the write path.

Uninstall tore down in the wrong order: it dropped the update badge, the release
notes and the desktop entry BEFORE attempting the removal that can fail, so a
directory that could not be deleted left the app on disk with its integration
already ripped out. The fallible step runs first now, the failure path is a
clean no-op with a toast, and removing something already absent reports success
instead of closing the dialog in silence.

Uninstall was also the only action in the detail view not gated on a running
download - clicking it mid-install remove_dir_all'd the directory the detached
blocking task was still renaming into, and the install died with a bare ENOENT.

Cancel never refreshed install status. The install runs in a detached blocking
task that runs to completion, so a cancel landing after the rename left the app
genuinely installed while the grid still showed it as not.

Disabled buttons rendered identically to active ones. Every action button was
written as a match on Hovered, Pressed and a catch-all, so Status::Disabled fell
into the catch-all: during a download, Launch, Update, Download, Update All,
Refresh and the sidebar badge all looked clickable and did nothing. They now go
through two shared helpers in theme.rs, so the next button cannot regress.

The sidebar's update badge kept offering a download on package-managed installs,
where it can only produce a warning toast - Settings > About already showed
guidance instead. It now does the same.

Device-flow login had no way to recover from a browser that would not open: the
verification URI was fetched, marked dead_code, and thrown away, while the panel
said only "Enter this code on GitHub". The URL is now carried into the state and
rendered as a clickable line (through the existing http(s) gate), and a failed
open is logged instead of discarded.
fetch_colony_manifest returned a plain Err for two entirely different things:
the network refused, or the repo's own colony.json is malformed. The call site
treated both as transient, filed the repo in transient_failures, and merged the
stale cached entry back in - so a maintainer who typoed their manifest (a
missing name, or "platforms": "linux" where a list belongs) got no signal at
all, and the store kept serving the last-good manifest indefinitely while their
new release never appeared.

Content-level failures now carry an InvalidManifest marker in the anyhow chain.
Those log at error with the serde message, which names the offending field, and
do NOT resurrect the cache. Transport failures keep the existing behaviour,
where the cache is exactly what you want.

Two more silent swallows in the same path:

auto_detect_release's failure was logged at debug. That is why Eidos sits in the
catalog as a card with no platform chips and no Download button while nothing
anywhere says which check failed - its assets are named
eidos-1.12.0-x86_64-linux.tar.gz, which matches no <name>-<platform>
convention. It is the app author's actionable signal, so it is a warning now.

The README write was unguarded while its three siblings (licence, changelog,
icon) were not, so a transient README failure persisted the repo's one-line
GitHub description over the good cached README. One rate-limited refresh
permanently degraded every affected detail page to a single sentence, and the
only way back was another successful fetch the user could not trigger while
still rate-limited. The in-memory description still falls back so the card has
text; the disk cache no longer does.

Finally, the detail page told two different stories with one label. A manifest
declaring platforms, none of them yours, genuinely has no build for you. A
manifest declaring none at all means Colony could not match the repo's assets to
any convention, which reads as "Colony is broken" when it says only "not
available for your platform".
The docs had drifted far enough to send bug reporters somewhere real code never
writes. Every cache and config path in architecture.md and faq.md pointed at
~/.cache/colony/ and ~/.config/colony/, while the code has always used
~/.config/Colony/Colony/{cache,repo-docs,repo-icons,preferences,auth}. The
source-tree map still described update.rs, github.rs and i18n.rs as single
files; they became directories in 430f238. The test count said 105; it is 131.

Also scopes the default log filter. A bare "info" let iced_winit and wgpu print
a pretty-printed WindowAttributes and a full adapter dump on every launch, which
drowned Colony's own lines in the very file a bug report attaches - measured at
5.8 KB of which almost none was ours. The default is now "warn,colony=info", so
their warnings still come through; RUST_LOG overrides it verbatim as before.

~/.cache/colony/ is now genuinely where diagnostics live, which is what
CONTRIBUTING and the FAQ were already telling people.
…lease

Three parts of one problem: the trust chain stopped at the launcher.

Rotation was not expressible. src/signing.rs embedded exactly one public key, so
the single <asset>.sig a release carries is either old-key (refused by every
updated client) or new-key (refused by every client in the field) - and
verification is fail-closed, so the refusal is permanent. The procedure in
docs/release-signing.md instructed both at once and could not be carried out in
either direction: a planned rotation stranded the install base, and an emergency
one after a leak left the defender with nothing anyone could verify while the
attacker held a key every client trusts.

RELEASE_PUBLIC_KEYS is now a list and a signature is accepted if any listed key
validates it, which makes rotation a real three-release sequence: ship N
embedding [new, old] but SIGNED with old (everyone accepts it, and afterwards
trusts both), sign N+1 with new, drop old in N+2. The docs now describe that
sequence, including why N must be signed with the outgoing key and what skipping
to N+2 costs. A test asserts any listed key verifies while an unlisted one never
does, and that the shipped list is still length 1 - so starting a rotation means
deliberately updating that assertion.

App assets got the signed metadata sidecar. Their entire trust check was a
raw-bytes ed25519 verification: proof the bytes came from the org key, but not
which artefact or which version they are. For an app with "signed": true - the
configuration users are told is the strong one - a compromised maintainer could
take a genuinely signed binary from an old, known-vulnerable release, re-upload
it under a new tag, and Colony would offer it as an update and Update All would
install it in one click, every indicator green. The .meta machinery already
existed and was reachable only from the launcher's own self-update.

download_release_asset now fetches {url}.meta and {url}.meta.sig alongside the
signature and runs the same bindings: asset name, digest, and the tag it
resolved. The version rule differs by caller, so it is split out - the launcher
keeps "strictly newer than the running build", apps get ">= what is installed",
because an app pinned to a fixed tag must stay reinstallable. Sidecars are
opportunistic and pinned once seen, the same one-way ratchet as .sig, so the org
can roll them out repo by repo without breaking anything and no repo can quietly
stop.

Finally, the release template Colony hands to app authors produced unsigned
releases with floating action refs, while the spec tells those same authors to
set "signed": true - which fail-closes their own installs. It now carries a sign
job modelled on Colony's own (single ASSETS source of truth, fail-if-secret-
missing, verify-before-upload, chained as needs: because a release-please
release does not fire release events), and every action is pinned to a commit
SHA - checked against upstream - since a floating tag on a workflow that holds
the org signing key is a supply-chain hole.
The product page could not answer the three questions every app store answers.
The API returns all of them and Colony discarded two: Asset kept only `name`,
so `size` was thrown away and the download total was learned from
Content-Length once the transfer had already started; Release kept no
`published_at`; and the tag being offered was rendered nowhere at all, only the
installed one. Size and date now ride along with the release-notes fetch, so
they cost no extra request per card, and the tag comes from the manifest for
free.

Release notes were unreachable unless an update was already pending. The whole
panel lived inside `if let Some(new_tag) = self.available_updates.get(...)`, so
a user evaluating an app, or checking what changed in the version they run, got
nothing - and `fetch_release_notes` already had a fallback to the manifest's
pinned tag that nothing could call. The Changelog tab is not a substitute: it
reads a cached CHANGELOG.md many repos do not have.

The grid advertised "Get" in accent colour for apps with no build for this
platform, right next to the chips saying which platforms they DO support. The
user clicked in expecting to install and hit the detail page's dead end. It now
reads "Not available", dimmed.

The catalog kept GitHub's push order, so the store reshuffled between sessions
for reasons invisible to the user - a README typo fix jumped a repo to the top -
and muscle memory never formed. Local apps were already sorted; the store is
now sorted too, by display name.

Which is the last one: the spec defines manifest `name` as "Display name in
Colony", two of the eight published manifests deliberately set one that differs
from the slug, and Colony used it only in the launch button - so a card titled
"Lilypad-Vault" carried a button reading "Launch Lilypad", and the desktop menu
got the slug. `display_name()` is now used for the card title, the detail header
and the .desktop Name=, while `name` stays the identity key for install paths,
caches, favorites and the entry filename, none of which may follow a string the
repo can change at will.
Two of the eight manifests the org publishes are broken and nothing on either
side of the ecosystem detects it: there is no schema, no lint, and no CI step
anywhere. `colony validate-manifest [PATH] [ASSET...]` closes that. It checks
the shape (unknown platform keys, a platform declared with no releaseFiles
entry, neither file nor filePattern, both at once, a pinned sha256 against a
moving "latest" tag, a non-PNG or non-relative icon) and, when given the asset
names a release publishes, checks that every platform actually RESOLVES.

That last part is the one that matters, because the failure that really happens
is a manifest which parses perfectly and still leaves the app listed with no
Download button. Run against the live org it reports exactly that for Eidos
(assets named eidos-1.12.0-x86_64-linux.tar.gz match no convention), while
confirming Grape auto-detects four platforms and SphereCord's glob-with-
exclusions resolves to SphereCord-3.3.3.AppImage out of 30 real assets. It
exits non-zero, so it works as a CI gate, and the release template now runs it
before signing. Decentralised: each repo validates itself.

The manifest's `signed` field was missing from the spec's field table
entirely.

Browsing also lied in three places:

Search was section-scoped, and the shipped "All" section filters to
`origin: colony` - which scanned apps can never be (entries Colony writes are
skipped by the X-Colony-Managed bail; everything else is External or Windows).
So typing "firefox" in the default view reported zero results on a machine
where Firefox sat two clicks away. A search that lies about zero results is
worse than no search. It is global now; Favorites stays scoped, being a chosen
set rather than a browsing filter.

The Windows and Linux sections consulted only the category filter, which is
None for them, so both listed the ENTIRE store next to the locally scanned
apps - on Linux, clicking "Windows" showed linux-only repos. They now filter
the store half by the manifest's own platform data.

And every installed app was filed under `Categories=Utility;` in the desktop
menu regardless of what its manifest declared, so a music player and two games
landed in the same GNOME/KDE section. The manifest category now maps to the
freedesktop main categories (Security, which has no main category, is correctly
paired as `Utility;Security;`).
Preferences could destroy themselves. Every state writer used a bare fs::write,
so a crash or OOM mid-write left truncated JSON; the loaders could not tell that
apart from "no file", returned defaults, and the next save - triggered by
something as innocuous as clicking a sidebar section - overwrote the corrupt
file with them. Writes are atomic now (temp + rename, the same discipline the
install path already used), and a file that fails to parse is moved aside to
<name>.corrupt with a warning instead of being clobbered.

auto_accent had a working toggle, applied at boot, that was simply never
written, so it reset on every restart. It is saved now, and save_preferences is
an exhaustive struct literal with no `..` on purpose: the compiler is what
catches the next forgotten preference. The three keys that were hardwired to
None on every save (close_behavior, update_channel, auto_install_updates) were
read by nothing and are gone.

The declared MSRV was 1.80 and could never have built - 70 transitive
dependencies require more, with iced, zip, image and wgpu at 1.88 - and nothing
checked, because CI only ran on stable. It is 1.88 now (verified against the
resolved graph), and CI has a leg that builds on whatever Cargo.toml declares,
so it cannot drift again. CI also gained clippy --all-targets, which had never
seen the tests, and an advisory-only RustSec scan.

The .desktop reader took the LAST occurrence of Exec, Icon, Categories,
NoDisplay and Hidden while glib takes the FIRST - the rule Colony's own writer
documents and depends on. Only Name had the guard, so a duplicated key made
Colony read a different entry than the desktop environment runs. Fixed, with the
first test parse_desktop_file has ever had.

52 locale keys (104 entries across the two locales) were dead: settings never
built, a replaced welcome carousel, placeholders for features that never
shipped. Deleted, with a test that reads the source and fails on any key nothing
looks up.

cached_get returned a RateLimitInfo that all six callers discarded, so it read
like a supported channel while being dead weight; it returns the body now and
the type is private to the HTTP layer. The blanket dead_code allow over the
38-method Palette impl is narrowed to the two accessors that are genuinely
unused, and the stale one on Message::DownloadProgress - which is both
constructed and matched - is gone.

Docs: the theme counts said 24 families where there are 25, and the Stellar
Blade family (five variants) was missing from the README's table entirely; both
are fixed and a test now fails if the numbers drift. Security was missing from
three user-facing category lists. The Linux build-dependency list named
libasound2-dev and libglib2.0-dev, which nothing in the crate graph needs and CI
does not install.

Two audit findings did NOT survive checking and are deliberately not "fixed":
the claim that the shipped binary contains OpenSSL (cargo tree --target all -i
openssl-sys finds nothing), and the claim that a whole theme family was
undocumented AND the counts merely stale - the counts were wrong by one, which
is what the test now pins.
…ecrets

release-please's own step published the release - and moved /releases/latest -
before a single binary existed. For the whole build+sign window every running
Colony showed the sidebar update badge and every click on it failed: a missing
asset first, then a fail-closed signature refusal once build had uploaded but
sign had not. The README's "download from the latest release" pointed at a
release with zero assets over the same window, and if any build leg failed that
state was permanent until someone noticed.

The release is now held as a draft immediately after creation and published by
the sign job, after it has verified every asset, signature and sidecar is
really there. Done with one gh call rather than a release-please config file:
the config-file route means switching the action to manifest mode, which
changes the outputs contract that build, sign and aur all depend on and that
nine releases have proven. This narrows the window from minutes to the second
between two steps, with no risk to version resolution.

Nothing ever ran a release artefact. The signature checks prove provenance and
say nothing about whether the binary starts, so an asset built for the wrong
architecture, or one that aborts under the release profile's LTO, would be
signed, verified, published and hashed into the AUR PKGBUILD with every check
green. Now that --version exists, each native leg runs it; the Intel macOS
asset is cross-compiled on an arm64 runner and cannot be executed there, so its
architecture is asserted with `file` instead - that being the leg with the
fewest users and therefore the one where a regression survives longest.

The AUR job was called with `secrets: inherit`, which handed it
COLONY_SIGNING_KEY_PEM along with the one secret it needs. That is the single
secret whose compromise is unrecoverable: every install in the field trusts the
key it signs with. The reusable workflow now declares AUR_SSH_PRIVATE_KEY
explicitly and receives only that. Its ssh-keyscan was also trust-on-first-use
on every run; the host key is pinned and cross-checked, and the release stops if
it disagrees.

(The pinned key was verified against the live host, not written from memory -
the value I first reached for was wrong and would have broken the publish.
Fingerprint SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4.)

Docs: the FAQ told users to cancel a download with Esc, which nothing binds,
and blamed startup crashes on GTK and xdo libraries that are not in the
dependency graph at all. The tutorial's "Colony retries automatically" was false
when written and is now true, so it says what actually happens. release-signing.md
was in neither documentation index. The bug template still suggested v0.1.4 as
the version placeholder at release 0.9.2. The categories.json / colony.toml
override search order was implemented and documented nowhere a user would look,
so the FAQ now explains it. The README's macOS install skipped the Gatekeeper
quarantine step the tutorial documents correctly. And the keyboard reference
described bindings that predate grid navigation.
… copy

Colony carried its own copy of the ecosystem release-workflow template, and
Project-Colony-Resources carries the canonical one next to the shared signing
script and the manifest schema. Two hand-maintained copies of the same file is
what produced the bug found today: the Resources template named a secret
(COLONY_SIGNING_KEY) that no repository actually has, and skipped signing on
Windows entirely - so a program following it shipped an unsigned .exe that
Colony then refused, because the manifest said "signed": true. Neither mistake
was visible from either side.

Colony's copy becomes a pointer explaining where the real one lives, why it is
a pointer, and the four things an app author needs. README, CONTRIBUTING,
docs.md and colony-spec.md now link to the canonical file.

Colony's own release-please.yml is deliberately NOT the template and stays
separate: it self-updates, so its signature verification is mandatory rather
than opt-in.
All four failures on the first run were introduced by this branch, and none
were visible from a Linux-only local check.

The MSRV job read the version with `cut -d'\"'`. Inside single quotes the shell
passes two characters to `cut -d`, which takes exactly one, so the command
errored, the output was empty, and the toolchain action failed to parse an
empty toolchain. Read with `tr -d ' "'` instead, and fail loudly if the result
is empty rather than handing an empty string downstream.

Clippy failed on Windows and macOS: `AppCategory::desktop_categories` is
consumed only by `write_desktop_entry`, which is `cfg(target_os = "linux")`, so
the method is genuinely dead in a non-Linux binary. Allowed narrowly there
rather than moving the whole mapping behind a cfg - it is platform-neutral data
and its test runs everywhere.

The advisory job gated the PR, which is the opposite of what its own comment
promised. rustsec/audit-check fails the check by default; `continue-on-error`
makes it report as intended. This matters beyond tidiness: quick-xml
(RUSTSEC-2026-0194/0195) is held at 0.38 by wayland-scanner via winit and iced
and cannot be updated from here at all, and it is reached only as a build-time
proc-macro parsing the system's own Wayland protocol XML - never untrusted
input. Gating on it would block every unrelated fix until iced moves. That
assessment is now written in the workflow so nobody has to re-derive it.

h2 was genuinely affected (RUSTSEC-2026-0258, unbounded empty DATA frames) and
reachable through reqwest, so the lockfile moves 0.4.13 -> 0.4.19. quick-xml
stays, for the reason above.
…rever

continue-on-error stopped the workflow failing, but the job itself still
reported failure, so the check sat red on every pull request permanently. A
check that is always red is a check nobody reads - which is worse than not
having it, because it also teaches people to skim past the ones that matter.

Ignore the two advisories that genuinely cannot be acted on from this
repository, by id and with the reason recorded, and let everything else fail
the build. RUSTSEC-2026-0194 and RUSTSEC-2026-0195 are quick-xml, held at 0.38
by wayland-scanner through winit and iced 0.14; both concern parsing untrusted
XML, and wayland-scanner is a build-time proc-macro reading the system's own
protocol descriptions, so neither is reachable at runtime. The comment says
when to remove them.

Now red means something new and worth looking at.
@MotherSphere
MotherSphere merged commit 89028f7 into main Aug 27, 2026
5 checks passed
@MotherSphere
MotherSphere deleted the claude/colony-improvements-598c1e branch August 27, 2026 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant