Repository navigation
feat(desktop): verify the signed update manifest; notify-only check state (#477 3.2a) - #529
Merged
Merged
Conversation
…tate Adds the desktop half of outcome #477 item 3.2, verifier side. The app embeds release/update-manifest-keys.json and, only when it names a current key, fetches update-manifest.json and its .sig from the latest release over HTTPS (64 KiB cap, timeouts), verifies them byte-for-byte as scripts/update-manifest.mjs does, and reports one bounded state. It never executes, downloads a DMG, installs or restarts anything. With current: null (today) no HTTP client is constructed and the state is disabled. - update_manifest.rs: pure verifier with a JavaScript-semantics canonical JSON reader; signature before parse, canonical bytes, schema, key slot, validity window with 300 s skew, stable-semver "newer" check. - update_check.rs: app-scoped update-check.json (atomic write) with last_check, skipped_version, last_seen_version, a checks setting and failure backoff; launch-delayed daily scheduler; main-webview-only commands update_status, update_skip_version, update_dismiss, update_set_checks_enabled. - Cross-implementation vectors generated by the Node reference with public test-only keys (pnpm generate:update-manifest-vectors), checked for drift. Refs #477 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Persisted-state compatibility, save-error handling, dismissal lifetime, upgrade detection, and initialization failure handling remain incorrect.
Review effort: Balanced
Findings: 5
Open (5)
Expose app data directory failures as an explicit unavailable state · New Reject newer persisted schemas instead of downgrading and overwriting them · New Only label semantically newer versions as upgrades · New Propagate persistence errors from user-initiated setting updates · New Preserve dismissal across transient check failures · New
What changed in this PR
Adds the desktop backend for signed, notify-only update checks.
Changes:
- Implements manifest verification, fetching, scheduling, and persisted state.
- Adds Tauri commands and main-webview-only capabilities.
- Adds cross-runtime vectors, tests, dependencies, and release documentation.
| File | Description |
|---|---|
scripts/generate-update-manifest-vectors.mjs |
Generates signed interoperability vectors. |
scripts/generate-update-manifest-vectors.d.mts |
Declares generator exports. |
package.json |
Adds the vector-generation command. |
native/desktop/psyche-build-tauri/src-tauri/test-fixtures/update-manifest/vectors.json |
Stores generated verification vectors. |
native/desktop/psyche-build-tauri/src-tauri/src/update_manifest.rs |
Implements manifest verification. |
native/desktop/psyche-build-tauri/src-tauri/src/update_check.rs |
Implements update state, persistence, fetching, and commands. |
native/desktop/psyche-build-tauri/src-tauri/src/lib.rs |
Registers update modules and commands. |
native/desktop/psyche-build-tauri/src-tauri/src/app.rs |
Initializes update state and scheduler. |
native/desktop/psyche-build-tauri/src-tauri/permissions/autogenerated/update_status.toml |
Adds status-command permissions. |
native/desktop/psyche-build-tauri/src-tauri/permissions/autogenerated/update_skip_version.toml |
Adds skip-command permissions. |
native/desktop/psyche-build-tauri/src-tauri/permissions/autogenerated/update_set_checks_enabled.toml |
Adds settings-command permissions. |
native/desktop/psyche-build-tauri/src-tauri/permissions/autogenerated/update_dismiss.toml |
Adds dismiss-command permissions. |
native/desktop/psyche-build-tauri/src-tauri/Cargo.toml |
Adds cryptography and HTTP dependencies. |
native/desktop/psyche-build-tauri/src-tauri/Cargo.lock |
Locks new dependency trees. |
native/desktop/psyche-build-tauri/src-tauri/capabilities/main-update-check.json |
Restricts commands to the main webview. |
native/desktop/psyche-build-tauri/src-tauri/build.rs |
Registers command permissions. |
docs/RELEASE.md |
Documents desktop update checks. |
__tests__/updateManifestVectors.test.ts |
Guards vector reproducibility and key separation. |
__tests__/tauriDesktopTabs.test.ts |
Verifies capability isolation. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review follow-ups for the update verifier (#529): - Read update-check.json field by field, so a future last_outcome, a negative counter or a mistyped field no longer discards a valid checks_enabled=false (which silently re-enabled checks). - A failed check keeps an update this session already verified, and its dismissal, until that manifest expires; only available/not_newer replace it. last_outcome is exposed so the failure stays visible. - upgraded_from is reported only for a move to a strictly newer version, not for a downgrade. - New cross-implementation vectors: base64 with non-zero trailing bits, an out-of-range s, and the two documented intentional differences (a small-order key refused by verify_strict, and the 32-level nesting cap), marked rust_expect and pinned by both test suites. - RELEASE.md: failure retention, independent checks per app instance, lenient state reading, downgrade behaviour, vector divergences. Refs #477 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot review follow-ups for #529: - If the app data directory cannot be resolved, log a bounded error and report state `unavailable` (storage `unavailable`) instead of idling. - Validate the state file schema: missing or legacy loads as v1; a newer schema is read but never rewritten (storage `newer_schema`), so a downgrade cannot destroy a later release's state. - update_skip_version and update_set_checks_enabled return a bounded error when the choice could not be saved and leave the in-memory choice unchanged; background check bookkeeping only logs. - Track the dismissed version explicitly; only an offer of a different version clears it (not_newer and failures keep it). Refs #477 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vector freshness check failed in CI: the generator signs a case in which the trusted key is the small-order identity point and asserts the Node reference accepts it. That outcome depends on the Node/OpenSSL build: Node v24.18.1 (local) accepts it, while Node v24.20.0 (CI's `node-version: 24`) refuses it with signature_invalid, so generation threw in CI. Remove that case from the cross-implementation vectors and pin the Rust behaviour (verify_strict always refuses a small-order trusted key) with a Rust-only unit test, small_order_key_is_refused. Every remaining vector now regenerates byte-identically under Node 20.10.0, 22, 24.18.1 and 24.20.0, and under other TZ/locale settings; the freshness check is unchanged. Refs #477 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BunsDev
added a commit
that referenced
this pull request
Oct 3, 2026
…#530) * feat(desktop): notify-only update banner Shows the verified update status from update_status (part a) as a polite live-region banner when, and only when, the state is available and the version is neither skipped nor dismissed. Instructions follow the inferred install source: a copyable `brew upgrade --cask psyche-build` for Cask installs, the release page plus this architecture's DMG SHA-256 from the manifest for DMG installs, and both when unknown. The only buttons are "Skip this version" (persisted natively) and "Dismiss" (Escape too); there is no install button and nothing downloads, installs or restarts. A "Check for updates" setting under Appearance appears only in builds with a provisioned key. New update.bundle.js is produced by the canonical build:web command. Refs #477 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): banner focus return, quieter live region, toggle resync Review follow-ups for the update banner (#530): - Only the title line is the polite live region, kept as one element across renders, and an unchanged model re-renders nothing, so screen readers do not hear the command or SHA-256 again on each refresh. - When the banner hides while it holds focus (Skip, Dismiss, Escape), focus returns to the element focused before it, or to the active terminal. - A refused update_set_checks_enabled puts the checkbox back to the last known native setting. - Document that Escape dismisses only while focus is inside the banner. update.bundle.js rebuilt with build:web. Refs #477 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(desktop): keep the update banner clear of native webviews; consume Escape Copilot review follow-ups for #530: - Native browser webviews draw above every DOM layer inside .detail, so an overlay banner there could be covered and lose its clicks. The banner is now its own normal-flow row of .app between the workbench and the footer (rows: titlebar, workbench, notice, footer). Showing it shrinks .detail, and the existing ResizeObserver on .detail resyncs native bounds. - Escape consumed by the banner calls stopPropagation, so it no longer bubbles to main.js's document Escape cascade and exits pane focus mode. update.bundle.js rebuilt with build:web. Refs #477 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): assert banner/footer adjacency without regex sanitization CodeQL flagged the test's comment-stripping replace as incomplete multi-character sanitization. The test only needs to assert that nothing but whitespace and complete HTML comments sits between the banner section and the footer stack, so match that whole span with a full-match pattern instead of stripping comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
5 tasks
This branch was successfully deployed
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.

Summary
Desktop half of #477 item 3.2, split in two because the full change is well over 800 non-generated lines. This is part (a), the Rust verifier, app-scoped state and commands. This PR has no UI. Part (b), the banner, is #530, stacked on this branch.
src-tauri/src/update_manifest.rs: a pure, byte-for-byte port ofverifyManifestinscripts/update-manifest.mjs. It includes a small JSON reader that modelsJSON.parse/JSON.stringifyexactly: strings as UTF-16 code units with lone surrogates allowed, numbers as doubles with safe integers only, the last value winning for a duplicate key, and keys sorted by UTF-16 code unit. That way canonical re-serialization equality matches the reference on edge cases, not only on well-formed input.src-tauri/src/update_check.rs: fetching, bounded states, app-scoped persisted state, the scheduler, and fourmain-webview-only Tauri commands.pnpm generate:update-manifest-vectorshas the Node reference sign 44 cases and records the reference's outcome for each. The Rust tests require identical outcomes, and a vitest test fails on drift.docs/RELEASE.md§ "Desktop update check".Owner decisions implemented (2026-10-03)
relaunch, no exit. The only network requests are two GETs for small JSON files.Inert until a key exists
release/update-manifest-keys.jsonis compiled into the binary withinclude_bytes!inupdate_check.rs.build.rsis not involved; it only registers the new commands. Whilecurrentisnull, which is the checked-in value,check_oncereturnsdisabledbefore the fetcher factory runs. No HTTP client is constructed and no request is made. A test asserts this with an injected factory that counts constructions. Checks are alsodisabledon Windows and Linux, where no update is published (the HTTP client is compiled only for macOS), and in the acceptance profile, which also keeps no state file. A malformed keys file does not fail the build. The built app treats it as having no key, so it stays inert with statedisabled. The Rust testembedded_release_keys_file_parsesfails in CI if the checked-in file stops parsing.States
disabledoffupdate_set_checks_enabled(false)).unavailableapp_data_dir()could not be resolved. A bounded error is logged, nothing is stored, and no check runs.idle/checkingunreachableoversizeinvalid_signaturesignature_malformedorsignature_invalid.unknown_keyunknown_key_id.non_canonicalmanifest_not_canonical.malformedmanifest_malformed, for example a schema failure or a tag that is notv<version>.not_yet_valid/expiredpublished_atminus 300 s toexpires_at.not_newerx.y.z-devcounts as older thanx.y.z.availableEach failure ends the check in that state. Nothing else happens, and the next check backs off: 24 h doubles per consecutive failure, up to 7 days.
Persisted state
The app writes
update-check.jsoninapp_data_dir()(~/Library/Application Support/dev.opencoven.psyche), through a sibling temp file,fsync, then an atomic rename. It holdschecks_enabled,last_check,last_outcome,skipped_version,last_seen_versionandconsecutive_failures. Loading is bounded to 16 KiB and reads field by field. A missing, mistyped, out-of-range or unknown value (for example alast_outcomewritten by a newer build before a downgrade) falls back to that field's default only. A validchecks_enabled: falsetherefore always survives. Corrupt or hostile values never reach the status the app reports. Schema: a missing or legacyschemaloads as v1. A newer schema is read but never rewritten, andupdate_statusreportsstorage: "newer_schema", so a downgrade cannot destroy a later release's state.storageis one ofnot_loaded,ok,newer_schemaorunavailable. Each app instance checks on its own schedule. If two instances write the file, the atomic rename means the last writer wins and the file is never torn. Project configs are never read or written.Post-upgrade: the first launch of a new version records it as
last_seen_version. Only when the new version is strictly newer does it expose the previous value asupgraded_from; a downgrade reports nothing. Reconciling open project configs through the #464 gate is out of scope, because it needs the CLI-side config gate and is not trivial from the desktop crate.Commands and capability
update_status,update_skip_version(accepted only for the version currently on offer),update_dismiss(in memory, tracked as the dismissed version and cleared only when a different version is offered) andupdate_set_checks_enabled. Each checkswebview.label() == "main". The two preference commands return a bounded error, such asupdate preference not saved: PermissionDenied, when the save fails, and leave the in-memory choice unchanged. Failed saves of background check results are only logged. They are granted only by the newmain-update-checkcapability (local: true,webviews: ["main"]), never bydefaultand never topsyche-browser-*.tauriDesktopTabs.test.tspins this.Risk: R3/R4
The change touches
src-tauri/**, adds network I/O, persistence, and dependencies.Failure boundaries:
unreachableor a refused manifest, an update this session already verified, and its dismissal, are kept until that manifest'sexpires_at. Onlyavailableornot_newerreplace them.last_outcomereports the failure.https_only(true), at most 5 redirects, 10 s connect and 20 s total timeouts, a 64 KiB cap, and no cookies.verify_strictalso refuses small-order points. For any honest signer it accepts exactly what Node/OpenSSL accept, and it fails closed on contrived inputs.New dependencies
ed25519-dalek = "2.2"(default-features = false,std): verify-only Ed25519. Widely used, maintained by dalek-cryptography. Nothing in the tree provided Ed25519.reqwest = "0.13"(default-features = false,native-tls), macOS target only:reqwest 0.13.2was already inCargo.locktransitively.native-tlsuses Apple's Security framework and system trust store, so no TLS stack is bundled. The OpenSSL and schannel entries inCargo.lockbelong to other targets and are not compiled on macOS. No other HTTP client existed in the crate.Test vectors and keys
The vector keys are derived from fixed public labels so the file is byte-reproducible. They are test-only and publicly recomputable.
vectors.jsoncontains only public keys and signatures. The generator refuses to run, and a vitest test fails, ifrelease/update-manifest-keys.jsonever trusts one of them. No real key was generated, and no secret was touched.The vectors cover:
1.0,1e0, an unsafe integer,-0, duplicate keys, and code-point vs UTF-16 key order;24:00:00, artifact name mismatch, uppercase digest;The Rust tests add every single-byte flip of the valid manifest and signature, a wrong-key set,
no_trusted_keys, oversize, unreachable, not-newer, next-key acceptance, schedule and backoff, persistence round trip, corrupt-state handling, post-upgrade recording, the checks-off setting, and install-source detection.Intentional differences:
verify_strictassignature_invalid. Node's result depends on its OpenSSL build: v24.18.1 accepts it and v24.20.0 refuses it. It is therefore not a cross-implementation vector; the Rust-only testsmall_order_key_is_refusedpins it.manifest_malformedin Rust (the reader's stack bound) andmanifest_not_canonicalin Node. This is pinned by the vectordeep_non_canonical_document, markedrust_expect.Both suites assert that
deep_non_canonical_documentis the onlyrust_expectvector. The newer vectors also cover base64 whose only flaw is non-zero trailing bits (signature_malformedin both) and an out-of-range s = s + L (signature_invalidin both).Mutation check: switching key sort to code-point order, or
>=to>at expiry, makes the vector test fail.Verification
Run at head
a5565861cb4ee64b1b43b77a9dffd799a4282ebc:cargo fmt --check,cargo test --locked(510 passed, 0 failed),cargo check --locked, all with the repo toolchain 1.95.0pnpm --dir native/desktop/psyche-build-tauri build:web: no bundle driftnpx vitest run(throwaway HOME): 372 files and 5902 tests passed, 12 skippedpnpm typecheck: passbash ./scripts/agent-check fast: passNot done: the real app was not launched against the network. Nothing can be observed end to end until a key is provisioned and a signed release exists. That is the G5 operator record in the design doc.
Review follow-ups (commit
7472341c){"checks_enabled":false,"last_outcome":"future_state"}, a negative counter, mistyped fields, unknown fields and a non-object.upgraded_fromonly for a strictly newer version. Downgrade tested.verify_strictdifference).include_bytes!rather thanbuild.rs, a malformed keys file leaves the build inert, no UI here. RELEASE.md now notes that app instances check independently.Copilot thread follow-ups (commit
28f50964)app_data_dir()failure: bounded log plus theunavailablestate. Test:a_missing_app_data_directory_is_reported_and_runs_nothing.a_newer_schema_file_is_read_but_never_rewritten.user_choices_report_a_failed_save_and_change_nothing.a_dismissal_lasts_for_its_version_only.upgraded_fromon a downgrade was already fixed in7472341c.CI fix (commit
a5565861)The vector freshness check failed in CI because generation threw on
small_order_key_accepted_by_reference_only: CI's Node 24.20.0 refuses the small-order key that local Node 24.18.1 accepts. That vector was removed and replaced by a Rust-only test. Every remaining vector regenerates byte-identically under Node 20.10.0, 22, 24.18.1 and 24.20.0, and under different TZ and locale settings. The freshness check is unchanged.Rollback
Revert this PR. No persisted format other than the new, app-scoped
update-check.jsonis introduced, and an older build ignores that file. The feature is inert until a key is provisioned, so merging changes nothing observable today.Refs #477
🤖 Generated with Claude Code