Skip to content

Sync v0.2.x: encrypted profile backup, v2 control plane, and fleet sync - #37

Closed
anhtahaylove wants to merge 33 commits into
ProxyShard:mainfrom
huuhungn:main
Closed

anhtahaylove wants to merge 33 commits into
ProxyShard:mainfrom
huuhungn:main

Conversation

@anhtahaylove

Copy link
Copy Markdown

Syncs the v0.2.x work from anhtahaylove/ShardBrowser (encrypted profile backup, v2 control plane, fleet sync).

Opened as a cross-fork PR because the automation account has no push access here.

What this brings

Encrypted profile backup. Snapshots are sealed client-side; the server stores and moves ciphertext only. Canonical deterministic codec that rejects non-canonical input, envelope v2 alongside v1 (v1 archives still restore; a v1 reader refuses v2 rather than misreading it), and RFC 9180 HPKE tenant root-key grants using the OS CSPRNG.

v2 control plane. Additive migration, no v1 rewrite. Every mutation carries a signed authorization record bound to tenant, server instance, restore epoch, domain, issuer and validity window — a session token alone is not sufficient. Atomic idempotency ledger with exact response replay.

Fleet sync. Exclusive checkout with monotonic fencing tokens, chunked ciphertext upload through staging, atomic publication, ranged download. Streaming a 514 MB profile grows peak RSS by 1.8 MB (0.35% of payload); a buffering variant grows ~515 MB and fails the ceiling.

Verification

shared 108, server 84, src-tauri 48, MCP 29/29 (96 tools unchanged), Playwright 21. Reproduced from a clean checkout of tag v0.2.0 (23606e2). Server clippy -D warnings clean.

Guards are mutation-tested rather than assumed: fleet 7/7 and G7 7/7 mutants killed. The G7 drill initially killed only 3/7 and was rewritten.

Please read before merging

This will not merge cleanly. main here carries 5 commits the source repo does not
have (v2 update (152 Chrome), runtime/SDK sync, version bumps), so the lines have
diverged from merge base 39745d7. There are 14 conflicting files, concentrated in
src-tauri/ sources, the lockfiles/manifests and runtime.json.

This PR carries exactly 2 commits: #13 (v0.2.x implementation) and #14 (G7 drill and a
test-race fix).

I have not attempted a resolution — your 48 commits are yours to reconcile, and guessing at them from outside would be worse than leaving the conflicts visible.

Status

v0.2.0 is published as a pre-release, source tag only, no installers. The P-OP production-operator gate (named operator plus a completed recovery drill) is still blocked, so this is not cleared for production fleet use. v0.1.29 remains the Latest release and the auto-update target.

anhtahaylove and others added 30 commits September 2, 2026 14:34
* Improve MCP setup guidance in Settings

* Add MCP download status in settings

* Clarify MCP token setup step

* Add custom build label and MCP auto-detect

* Allow selecting existing MCP folder

* Remove unused launch import

* Clarify MCP repair and existing folder actions

* Point custom builds to fork releases

* Add ShardX MCP health and profile lookup helpers

* Add Codex-friendly MCP profile helpers

* Improve launcher profile launch error states

* Improve Automation API token settings UX

* Release v0.1.12 custom integration

Combine the MCP helper and Launcher UX branches, expose actual API/MCP readiness, and add CI plus signed release packaging for the fork. Pin the bundled MCP archive to the matching v0.1.12 release.

* Update GitHub Actions Node 24 runtimes

* Update release actions to Node 24 runtimes

* Improve MCP setup version status

* Clarify Codex MCP registration UX

* Add Codex MCP registration check

* Check release version parity

* Validate manual signed build requests

* Add ShardBrowser DevTools handoff helpers

* Fix Python SDK UTF-8 profile JSON IO

* Support proxy bulk import name tags

* Allow temporary profile noise overrides

* Add safe launch extras and custom font handoff

* Port team server auth and lock core

* Port snapshot safety shared crate

* style: format team server port

* chore: allow self-signed internal signing tests

* fix: accept code signing EKU friendly name

* fix: trust internal cert non-interactively

* fix: verify internal self-signed signatures

* fix: sign Windows portable artifact

* docs: update signed custom release notes

* Add MCP stale profile cleanup helper

* Release v0.1.14 custom cleanup helper

* Release v0.1.15 safe open cleanup fix

* Fix smoke cleanup for ShardX process trees

* Release v0.1.16 process-tree cleanup

* feat(ui): polish launcher states and accessibility

* chore(release): prepare v0.1.17

* fix(release): discard cached bundle outputs

* fix(release): keep cleanup command valid YAML

* feat(mcp): add human verification handoff

* feat(ui): show required verification status

* docs: add safe Cloudflare verification workflow

* chore(release): prepare v0.1.18 custom build

* chore(release): sync Cargo lock for v0.1.18

* feat: add Cloudflare verification pause and focus handoff

* feat(mcp): persist verification handoff checkpoints

* docs: design safe WP-CLI plugin fallback

* chore(release): prepare v0.1.19 custom build

* fix(mcp): derive server version from package

* fix(api): prevent listener inheritance on Windows

* chore(release): bump version to 0.1.20

* feat: prepare reproducible MCP and inline proxy creation

* docs: refresh custom release guidance

* chore(release): bump version to 0.1.21

* feat(updater): add trusted signed update flow

* fix(fonts): gate unsupported custom font handoff

* test(ui): add launcher regression coverage

* fix(ci): provision updater verifier dependencies

* fix(ci): clear handled internal signing exit code

* chore(release): prepare updater-signed v0.1.22

* fix(release): normalize assets before publishing

* fix(installer): make NSIS upgrades replace executable

* chore(release): prepare v0.1.23

* feat: add Launcher sign-in startup

* fix(release): include MCP contract test

* fix(startup): keep Windows restore launches in tray

* chore(deps): resolve npm security advisories

* chore(release): prepare v0.1.26

* fix(profiles): fail closed around active browser mutations

* chore(deps): resolve npm security advisories

* fix(profile): close lifecycle race conditions

* chore(release): prepare v0.1.27

* chore(deps): harden Rust SDK dependency patch

* ci: run RustSec without deprecated action runtime

* ci: keep updater verification on stable Rust

* fix(mcp): preserve active tab for safe follow-up tools

* fix(mcp): harden safe-open lifecycle and packaging

* fix(mcp): bind safe-open cleanup to owned process

* fix(mcp): close final lifecycle review gaps

* fix: bind profile cleanup to launch instances

* ci: enforce exact MCP archive manifests

* chore(release): prepare v0.1.29

* docs: preserve v0.2.x team fleet design

* feat(backup): add v2 encrypted profile backup container

Add the v2 backup format as five modules in the shared crate:

- canonical.rs: deterministic canonical CBOR that rejects non-canonical
  input, so signed bytes cannot be re-encoded into a different-but-accepted
  form.
- keys.rs: key identities and DEK wrap/unwrap with ChaCha20-Poly1305, bound
  to a canonical slot context as associated data.
- signing.rs: Ed25519 over a domain-separated, length-prefixed transcript,
  with acyclic commitments (a signature is never part of its own payload).
- envelope.rs: DEK slots, pre-encryption intent records, STREAM frame
  nonces/AAD, and the restore-epoch Merkle tree with inclusion proofs.
- backup.rs: the seal/open container API joining the above.

Frames bind the exact intent hash, frame counter and final-frame flag, so
truncation, reordering and cross-envelope splicing all fail closed. The
prologue is bound to the signed head, so substituting the slot or intent
before the signature check is rejected.

The crypto dependencies are pinned with exact-version requirements: they
define wire-visible behaviour (AEAD suite, signature encoding), so a silent
minor bump could change bytes that signatures commit to.

All golden vectors are pinned from the independently verified G2
conformance gate and reproduced byte-for-byte.

* style(shared): clear the pre-existing clippy warnings

Four warnings predating this work, so the crate can build under
-D warnings:

- snapshot.rs: replace a manual max/min chain with clamp. Safe because
  RATIO_FLOOR_BYTES (64 MB) is fixed below MAX_TOTAL_EXPANDED (4 GB), so
  the inverted-bounds panic is unreachable.
- logins.rs: separate a rustdoc list from the paragraph following it.
- snapshot.rs: use an array instead of vec! in a test fixture.
- webdata.rs: keep the local binding with a narrow, documented allow. The
  binding is load-bearing rather than redundant — it ends the borrow of
  stmt before the function returns, and inlining it fails to compile
  (E0597). Clippy's suggestion is wrong here.

* test(shared): add the G3 v1/v2 compatibility gate

Prove the v2 container did not disturb the v1 portable snapshot path,
against a real temporary user-data dir rather than fixtures:

- a v1 snapshot still round-trips through pack/unpack;
- sealing a v1 snapshot and opening it returns the exact v1 bytes, which
  still restore through the untouched v1 reader;
- the v1 reader rejects a v2 container instead of misparsing it.

Local State is deliberately excluded from the byte-equality check: the v1
restore path rewrites os_crypt.encrypted_key so the profile is decryptable
by the local machine. Asserting equality there would assert the opposite
of the intended portability behaviour, so the test checks the structural
invariant instead.

* release: v0.2.0

Bump the launcher, MCP server and Tauri app to 0.2.0 and document the
encrypted profile backup format in the changelog.

The MCP contract test pinned the served version, so it moves with the
bump. The tool count stays asserted at 96 — that is the compatibility
invariant, and it is unchanged.

* test(mcp): pin the v0.1.28 96-tool compatibility fixture

The baseline the v0.2.0 contract test is checked against. The .omx
evidence writeup stays out of the repo — it is ignored scratch space.

* feat(grants): add HPKE tenant root key grants

Seal a tenant root key to a device's HPKE public key (RFC 9180 base mode,
DHKEM-X25519/HKDF-SHA256/ChaCha20-Poly1305) so the server never holds the
root key in the clear.

The HPKE info is the byte-identical canonical TenantRootKeyGrantHpkeInfoV2
encoding, not a hash or a reconstruction, so tenant, device, recipient key,
server instance, restore epoch and suite are all bound into key derivation.
open_trk re-derives info and AAD from the caller's scope instead of reading
them back from the record: a tampered grant derives a different key
schedule and fails to open rather than opening and being believed. Tests
cover every scope field, per-byte mutation of both the ciphertext and the
encapsulated key, and the wrong-device case.

Sealing uses the OS CSPRNG. The G2 spike sealed with a deterministic RNG so
golden vectors could be reproduced byte-for-byte; carrying that into
production would repeat encapsulation randomness across grants, so a test
asserts two seals of the same key differ.

rand_core 0.10 (hpke 0.14's dependency) dropped OsRng, so a small adapter
bridges getrandom. It panics rather than falling back when OS entropy is
unavailable. getrandom is aliased because the Windows os_crypt path already
pulls in the 0.2 line.

* feat(server): add the v2 team/fleet control-plane schema

Additive migration for the v0.2 control plane: tenants, accounts,
devices, signed authorization records, HPKE root-key grants, fleets,
profiles, leases, snapshot manifests, the idempotency ledger and
resumable upload sessions. The v1 tables are untouched, so an existing
deployment migrates without a data rewrite.

Two invariants are enforced structurally rather than in application
code, because application code is the part most likely to be bypassed:

- Tenant isolation via composite (tenant_id, id) foreign keys. A child
  row cannot reference a parent in another tenant even when a query
  forgets its WHERE clause; SQLite rejects the write.
- The wire-integer domain from plan 5.6. CBOR's unsigned range is wider
  than SQLite's signed INTEGER, so every persisted wire integer is
  CHECK-constrained to 0..i64::MAX instead of silently corrupting on
  bind.

Exact signed container bytes are stored verbatim next to their parsed
columns; verification reads the bytes, never a re-encoding of the
columns, so divergence is detectable instead of trusted.

Idempotency keys are scoped to (server_instance_id, restore_epoch) per
plan V5.1, so a request replayed from before a restore cannot be served
a stale response.

Tests assert the database *rejects* things, checking exact SQLite
extended result codes so a weakened constraint fails loudly rather than
passing as a generic error. Durability tests use a real on-disk file
with close/reopen cycles per plan 11.3, since in-memory SQLite cannot
prove what survives a process exit.

* feat(server): verify signed authorization records on v2 endpoints

Adds the request-path half of the v2 control plane: records are verified,
consumed once, and filed under the tenant they name.

authz: verify_record checks, in order, that the container hash matches
its bytes, the signature is valid, the issuer is one this tenant trusts,
and that the record binds this domain, this tenant, this server instance
and the current restore epoch. Each of those is a separate rejection
reason so failures are diagnosable rather than a blanket 400.

The tenant binding is not redundant with the trusted-issuer check: a
tenant's own issuer can sign a record naming a different tenant, and only
the binding check separates the two. An e2e test presents exactly that
record and a mutation run confirms removing the check turns it into a 201.

idempotency: consume_replay_id claims a record via INSERT OR IGNORE on a
UNIQUE index, so concurrent presentations of the same record resolve to
one winner in the database rather than in application logic. A check-then-
act version of the same function admits two winners under 8 concurrent
tasks; the test covers that.

Completed operations store their exact response bytes, so a retry replays
the original result instead of re-running the effect.

Endpoints: device approvals, capability grants, tenant root key grants and
operation completion. The server files grants without being able to read
them — the payload stays HPKE-sealed to the recipient device.

server: 62 tests, clippy -D warnings clean.

* docs(changelog): record the v2 control plane in 0.2.0

* feat(fleet): add snapshot upload, commit, and download transfer path

Leases with monotonic fencing tokens make profile checkout exclusive, and
chunked staging keeps peak memory bounded by the chunk size rather than the
snapshot size. Commit is atomic: size, content hash, lease liveness, fencing
token and base version are all checked before the staged blob is promoted and
the version row is inserted in one transaction.

The server stores ciphertext it cannot read. Snapshot manifests carry their own
signed authorization domain so a capability grant cannot be replayed to publish
a version.

* test(fleet): measure bounded memory on a real profile tree

G6 asserts peak resident memory tracks the chunk window, not the payload.
Ignored by default and driven by SHARDX_G6_PROFILE_DIR, because it needs a
real multi-hundred-megabyte profile that CI does not have.

Measured on a 514 MB disposable copy of a real profile: peak RSS grew
1.8 MB across 2562 chunks. A mutant that buffers the payload instead of
streaming it grew 515 MB and fails the assertion, so the bound is
actually enforced rather than incidentally true.
…es (#14)

The G7 drill covers what unit tests cannot express: two devices sharing one
profile through a single server. Handover, concurrent checkout, fencing after
a stall, rejected-commit rollback, restart durability and tenant isolation.

Mutation testing drove the content: the first version killed only 3 of 7
removed guards. The fencing test asserted AlreadyLeased rather than a stale
token, so the token comparison itself was never exercised; base-version,
declared-size and chunk-ordering guards had no coverage at all. All 7 mutants
are now killed.

Also fixes a real test-isolation defect. The lifecycle claim map is a process
singleton, and profile::tests serialized on a private lock that tests in
lib.rs and api.rs could not reach, so they raced and intermittently observed
each other's claims. Reproduced at 1/40 runs under thread contention, now
0/120. The lock is shared via pub(crate) and taken by every test that claims.
Three follow-ups after the v0.2.0 tag.

Clippy: src-tauri now passes `--all-targets -- -D warnings`, which it did
not at the tag (7 findings, all pre-existing). The arity finding on
mcp_status_value was real and worth fixing rather than allowing: it took
six adjacent bools across five call sites, so transposing two would have
been a silent bug. They are now a named McpStatusFlags struct. The JSON
shape it emits is unchanged.

Test isolation: audited every test touching the four process-wide
singletons in src-tauri. All 7 already take the shared lifecycle guard,
and 150 runs at 32 threads produced no failures, so the class of race
fixed in #14 appears closed.

P-OP: adds the operator runbook and an ignored recovery drill covering
seal/recover on a fresh machine plus four refusal cases. Writing it
surfaced an operational hazard worth recording: because open() streams
authenticated frames and only detects truncation at the signed head, a
truncated container still emitted the entire 3,145,744-byte plaintext
before failing. That is the documented contract, not a defect, but it
means an in-place restore can leave an unauthenticated profile that
looks complete. The runbook leads with the promote-on-Ok rule and the
drill records the measurement.

The P-OP gate itself remains blocked: it needs a named operator and a
verifier, which no code change can supply.
…re (#16)

shared::backup::{seal, open} has no production caller. The Launcher
exposes no backup, restore, fleet or sync command and never contacts the
team server, and server/src/fleet.rs transports ciphertext that no
shipped client produces.

The code and its tests were accurate, but the prose around them read as
though the feature were usable. This corrects the framing in the
changelog and the P-OP runbook, and the published release notes have
been updated to match.

The runbook needed more than a caveat. Three of its four sign-off rows
(Launcher backup, downgrade against a real profile flow, artifact
digests) describe product flows that do not exist yet, so they are now
marked blocked. Only the recovery-bundle readback is runnable today.
Leaving them tickable invited signing off a library call as evidence for
a product flow, which is what the gate exists to prevent.

Section 0's promote-on-Ok warning is retained but re-scoped: it is a
constraint on the restore path whoever writes it, not a live hazard.
v0.2.0 shipped the sealing library and the fleet server with nothing in the
app able to reach them: `shared::backup::{seal, open}` had no production
caller and the Launcher had no backup surface at all (#17).

Add the two missing layers and the UI on top:

- `shared::passphrase` derives the backup FKEK with Argon2id (64 MiB, 3
  passes). Only a random salt is stored, so a backup opens on a machine that
  has never seen the source. The threat is an offline attacker holding the
  file, which is what rules out a fast KDF.
- `shared::backup_file` joins `snapshot::pack` to `backup::seal` and owns the
  self-contained `.shxbak` format. Files are written to a temp sibling and
  renamed, so an interrupted backup cannot replace a good file with a
  truncated one.
- `profile_backup_create` / `_restore` / `_inspect` Tauri commands, with the
  profile menu entries and a passphrase prompt.

Restore recovers the whole container in memory and only then calls
`snapshot::unpack`. `backup::open` streams authenticated frames and can only
detect truncation when it reaches the signed head, so restoring in place could
leave a profile that looks complete and was never verified. The P-OP drill
measured this: cutting the last 64 bytes still emitted all 3,145,744 plaintext
bytes before the error surfaced.

Both mutating commands take the same `begin_user_mutation` claim as
delete/clone, since a backup taken from a running profile is torn and a restore
under one corrupts it.
…ion (#19)

Adds the /v2/ client tracked in #17: lease, chunked upload, signed commit
and ranged download, so a sealed profile snapshot can move between devices.

The manifest format now lives in shared::fleet_manifest and is used by both
the signing client and the tests. Keeping a copy on each side is how a client
ends up building records the server rejects, with nothing catching it until a
real sync fails.

Also adds GET /v2/server-identity. A signed manifest binds to the live
server_instance_id and restore_epoch, and without an endpoint publishing them
a client could only guess.

Security fix, found by mutating the manifest builder and watching the commit
still succeed: /v2/fleet/uploads/commit verified the manifest signature and
then read container_sha256, profile_id, snapshot_id, fleet_id, base_version
and key_generation from the unsigned request body. A caller with a valid
token could attach a genuine manifest signed for one snapshot to a body
describing different bytes and publish those instead. The handler now refuses
any body that contradicts the signed record. The regression test publishes
version 1 when the check is removed.

fleet_client has no UI caller yet. That needs device enrollment, which the
server has no endpoint for, so #17 stays open.
…artifacts (#20)

v0.2.0 was tagged but published nothing: the validation job failed on
`npm audit --audit-level=moderate` over a high-severity browserslist
advisory, so no build or publish step ever ran. The release has zero assets
and v0.1.29 stayed the latest installable version.

Updates browserslist to 4.28.8 (lockfile only) and bumps the four version
declarations the workflow requires to agree.

Also reads the MCP contract version from package.json instead of a literal.
Pinning it means every release bump fails that test for no reason, which
teaches people to edit the assertion without reading what it guards.

The v0.2.0 tag is not moved or altered.
The eight /v2/fleet handlers take tenant_id from the request body or path
and never checked it against the caller. AuthUser carries no tenant, and
five handlers bound the extractor as _user, so any authenticated user of
the deployment could lease, upload to, commit to, abort, or download any
tenant's profiles by naming its ids.

The signed-record endpoints were not affected: a tenant issuer signature
covers their fields, and they only echo the verified hash rather than
acting on the body. The fleet endpoints carry no such signature, so the
boundary has to be enforced in the handler.

Every fleet handler now resolves the caller's v2 account in the named
tenant and refuses when it has none. Where the body also names an actor
- account_id on lease, author_account_id on commit - that value must
equal the caller's own account, so one member cannot act as another.

The regression test drives real HTTP with a second user that belongs to
no tenant. Against the previous code it gets 201 Created.
* Add device enrollment and Team settings UI

The fleet client had no way to obtain an identity: it took a signing key from
its caller, and nothing generated, registered or stored one. That left issue
#17 open regardless of the client existing, and left the module without a
caller, so the linker dropped it from the release binary entirely.

Server: add the two-step enrollment the schema already anticipated. The
client commits to its key pair when it requests a challenge, the server
stores only the nonce's hash, and registration requires a signature over the
challenge. Possession is proved, not asserted.

Launcher: generate the device signing key locally, prove possession, and
store it in team.json alongside the server URL and token. The key is read
back and compared before enrollment reports success, so a file that did not
round-trip fails here rather than at the first publish.

UI: a Team server card in Settings for the URL, tenant and token. The token
field clears after saving and is never returned by team_status; the UI only
learns whether one is set. Changing the server clears the enrollment, since
a device id is meaningless to a server that did not issue it.

The proof encoding lives in shared/src/enrollment_proof.rs rather than being
written twice. The manifest format was previously defined on both sides and
drifted; there is no reason to repeat that with a second signed record.

Verified by driving a real server process from the launcher's own client:
enrollment succeeds, and re-enrolling the same key is refused. The test
fails loudly when the server binary is absent instead of skipping, and CI
now builds the server so it cannot silently stop running.

Also refuses a duplicate enrollment explicitly. The unique index already
stopped it, but the client received a raw SQLite error naming tables and
columns.

* Generate key seeds through a cross-platform entropy source

The launcher only depends on getrandom under cfg(windows), so calling it
directly from enrollment broke the Linux build. shared depends on getrandom
0.4 unconditionally, so expose fill_random there and use it.

The 0.2 dependency in shared is also Windows-only, which is why this goes
through the 0.4 one.
Wire the fleet transfer client to the Launcher so profiles can be pushed to and
pulled from a team server. The client shipped in v0.2.1 with no caller, so the
linker dropped it: a release binary built from main contained the enrollment
routes but not /v2/fleet/uploads.

Sync uploads the same sealed container a local backup writes. The server stores
ciphertext, a digest and a version; it holds no key and cannot open what it
stores. The passphrase is therefore a shared secret between devices, which is
weaker than the per-device key wrapping the v2 design calls for. docs/key-custody.md
records why: the tenant root key machinery exists in shared/src/grants.rs but has
no caller, and POST /v2/tenant-root-key-grants verifies a grant and then discards
it, so a second device has no way to obtain the key.

Enrollment now returns the account id it assigned. Fleet routes are account
scoped and the client cannot infer that id; a device enrolled before this change
reports can_sync false and must re-enroll rather than guess.

Also audited every v1 route for the tenant-boundary flaw fixed in #21 and found
none: v1 derives identity from the session rather than accepting it from the
caller. docs/v1-authorization-audit.md records the one route that resembles the
flaw and why it is safe.

Tests: a real-process round trip pushes a 300 KB container through a live server
and asserts the downloaded bytes match, plus that a push from a stale base
version is refused. The running-profile boundary test now covers both sync
commands; it caught them checking configuration before running state, which
would have told a user to fix server settings when the real problem was a
running browser.
Bump the Launcher, MCP and Tauri manifests to 0.2.2 and record the release in
the changelog. This is the first build where device enrollment and profile sync
are reachable from the shipped binary rather than present only as source.
The v0.2.2 dry run failed in validation before any build step, the same way
v0.2.0 did: `npm audit --audit-level=moderate` runs against three workspaces and
the mcp one carried a high-severity fast-uri advisory and a moderate qs one.

Both arrive transitively through @modelcontextprotocol/sdk, so there is no
direct dependency to bump. Patched releases exist upstream and `npm audit fix`
resolves both by lockfile alone, leaving package.json untouched.

This also syncs mcp/package-lock.json to 0.2.2; the version bump reached
package.json but not the lockfile, which still read 0.1.29.

MCP tests: 29 pass.
The route table omitted GET /audit and POST /me/password. Both are sound --
/audit calls require_admin, /me/password acts on the session user's own id --
but an audit that silently skips routes is worth less than one that lists them.
The grant endpoint verified a record and discarded it, so the grants table
never held a row and a second device had no way to obtain the tenant root
key.

Grant fields are read from the signed field map rather than the request
body, so a caller cannot present another issuer's signature and redirect
the custody it authorises. The new collection route derives membership
from the session rather than the path.

This uncovered a silent failure in the replay ledger. Its record_table
CHECK omitted the root key grant table, and because the claim uses
INSERT OR IGNORE, the violation was indistinguishable from a duplicate
key: zero rows affected, reported as 'already used'. Filing a grant
looked like a replay of a record that had never been stored, and grants
had no replay protection because no ledger row was written. Migration
0005 widens the constraint while preserving existing rows, and the claim
now confirms the row exists before reporting a replay.
INSERT OR IGNORE reports a constraint violation exactly like a duplicate
key, which is how the root key grant ledger bug stayed hidden. The
operation ledger uses the same pattern: it claims with OR IGNORE, then
reads the row it expects to exist. A dropped insert surfaced as an
unexplained database error, so it now fails with a message naming the
likely cause.

grant_variant is the one CHECK-constrained column fed from a signed
client record. It was already validated in Rust before the insert, but
nothing proved it, so a regression test asserts an unknown variant is
refused with 400 naming the field.

The remaining OR IGNORE and OR REPLACE statements were reviewed: they are
cookie upserts, where replace-on-conflict is intended, and unconditional
Chromium meta seeding.
* Add root key generation lifecycle with FirstRootSelfGrant binding

Grants named a root generation, but nothing recorded which generations existed
or which one was in force. A grant could name a generation that was never
created and wrap an unrelated key; the mismatch would only surface later as a
backup nobody could open.

Add v2_root_key_generations and bind grants to it. A grant must name an
existing generation and wrap the key that generation committed to. A
FirstRootSelfGrant is accepted only while the generation is PREPARING and only
once per tenant, so custody cannot be appended to after the fact.

Activation is a separate step from filing. Filing proves a grant was written,
not that anyone can open it; activating on filing would let a tenant seal
itself under a key it had already lost, which the server cannot help recover
because it never sees a key.

The tenant column active_root_generation stays the number a tenant advertises;
the new table records the lifecycle the column cannot express. Activation
writes both in one transaction so they cannot disagree.

* Wait for the server to be ready in the new lifecycle tests

The new tests called the server immediately after spawning it. On a fast
machine the process was already listening; on CI it was not, so all four failed
with connection refused. Every other test in the suite waits on health first.
Enrollment generated an HPKE keypair, registered the public half, and dropped
the private half. A grant sealed to a device could therefore never be opened by
it, which broke the custody chain at its last step.

The seed is now persisted alongside the signing seed and cleared with it. A new
team_collect_custody command fetches this device's grants and opens each one,
reporting counts rather than the key. Devices enrolled before this report
can_receive_custody: false and are told to re-enrol instead of failing silently.

open_trk_with_info opens against the stored HPKE info bytes, since a collecting
device holds the grant but not the scope that produced it. A test proves the
two open paths agree and that altered info bytes still fail.

Also isolates the enrollment tests: they shared one data directory, so adding a
second test produced 'database is locked' rather than a useful failure.
The grant, generation, and fleet endpoints live in the server, but releases
only ever shipped the Launcher and MCP. v0.2.3 published its headline custody
feature as source only, which meant downloading the release did not get you a
server that serves it.

Each platform build now produces a server binary alongside the Launcher, and
the release job fails if any of the three is missing rather than publishing a
quietly incomplete set.
The publish job verifies that every platform's server binary made it into the
release, but that job is skipped on a dry run, so a packaging regression would
only be caught during a real publish. The per-platform build job now asserts the
binary reached staging, which every dry run exercises.
…nifest (#37)

* v2 update (152 Chrome)

* Update runtime.json

* Update package.json

* upd ver

* upd sdk runtime sync

* fix bugs, add shardhelper, human type, human mouse, trash, webcamera spoof, trash, bookmarks and etc.

* upd

* up_readme

* Update README.md

* Port the team, backup and sync UI onto the v2 frontend

The v2 rewrite replaced App.tsx, which held every team control. Rebuilds them
as a team entity, a settings card and profile row actions:

- Team settings card: server, token and tenant, with Generate for the tenant
  id so a fleet is not joined through a mistyped UUID, and a button to collect
  key custody. Says plainly when a device predates the stored HPKE seed and
  can never open a grant.
- Encrypted backup and restore, and push/pull, back on the profile menu. Team
  entries appear only once the device is enrolled and able to sync.
- Passphrase prompt as a store and host, matching the confirm dialog.
- Restores safeUiError, lost in the rewrite: it scrubs the API token out of
  error text before a toast renders it.
- Restores the startup toggles the v2 settings page dropped, and makes
  settings_save preserve any field the caller omits, so saving from a UI that
  does not know about a setting no longer silently turns it off.

* Add the fleet key (FKEK) generation lifecycle and its grants

Profile sync needs a per-fleet key, not the tenant root key. The root key
authorises custody; the FKEK is what actually wraps snapshots, so the two
have separate lifecycles and separate grants, per the frozen plan.

Server:
- migration 0007 adds fleet key generations and their device grants, with
  the same PREPARING/ACTIVE/RETIRED lifecycle as root generations and a
  unique index making the first grant of a generation the only self grant
- migration 0008 widens the replay ledger CHECK to accept fleet grants
- a fleet generation is anchored to the tenant's active root generation, so
  fleet custody cannot outlive the root that authorises it
- grants are read from the signed container, never the request body, and
  must claim the fleet.key.receive capability and sit inside their own
  validity window

Shared:
- fleet_grants seals and opens the FKEK with HPKE, bound to the grant scope

Verified: 102 server tests pass, including 5 new fleet cases. Disabling any
of the four fleet invariants in turn makes the suite fail, including the
capability check, which needed the test this commit adds.

* Sync profiles with the fleet key instead of a shared passphrase

A passphrase everyone types is a shared secret passed out of band. The
fleet key arrives sealed to each device's own HPKE key, so no secret has
to be agreed between people.

- team_collect_custody now also collects fleet key grants and caches the
  opened keys in fleet-keys.json, kept out of team.json so a Settings save
  cannot rewrite key material
- push seals under the active fleet key when the device holds one; pull
  tries every held generation, newest first, so snapshots pushed before a
  rotation still restore
- the passphrase path remains for devices with no grant yet, and the UI
  stops asking once a fleet key is held
- fleet containers carry their own magic, so feeding one to the passphrase
  path reports what is actually wrong instead of 'wrong passphrase'

Verified with a real two-device round trip against a server process:
device A seals under the fleet key, device B opens it with no passphrase,
and a device holding a different key cannot open it at all.

* Redact secrets in error toasts and explain disabled actions

Two issues the audit found in the merged UI.

Error toasts printed raw backend errors: 64 call sites passed the caught
exception straight through, and those strings can carry a bearer token, a
proxy password, key material or a passphrase. Redaction now happens inside
toast.err rather than at each call site, so a new caller cannot forget, and
the redactor also covers proxy credentials, long hex runs and passphrases.
Checked against samples of all five leak shapes.

Menu items that need a stopped profile were fully enabled and only failed
after being clicked. They are now shown inert with the reason as a tooltip.
Sync entries for a device enrolled before sync existed used to vanish from
the menu entirely, leaving no hint that re-enrolling is the fix; they now
appear with that explanation.

* Fix three high-severity advisories in the v2 build dependencies

browserslist, nanoid and postcss arrived with the upstream v2 toolchain.
Resolved by lockfile update only; no API surface changed and the build and
type check still pass.

* Restore the e2e mock hook and the states its tests cover

The v2 rewrite replaced the entry point, which silently dropped the import
that loads src/e2e-mock.ts, so every mocked test hit a real IPC bridge that
was not there. Restoring the hook exposed three real regressions the old
suite had been guarding:

- Browsers showed a blank screen when loading failed. The store tracked
  status and error but no component read them, so a failed backend call was
  indistinguishable from an empty account. It now announces loading, shows
  the error, and offers a retry; init() no longer short-circuits after a
  failure so the retry can actually re-run.
- The empty state always claimed there were no profiles, even when a search
  or folder filter had simply matched nothing, and offered Create profile as
  the fix. Filtered-empty is now its own state with a clear-search action.
- Icon-only row buttons had no accessible name, so they were unreachable by
  name for assistive tech and for tests.

The mock also answers the read-only commands v2 added. It threw on anything
unrecognised, which turned one unmocked list call into a blank screen.

* Restore v2 UI states the e2e suite guards

The v2 rewrite dropped behaviour the suite asserts. Restore it rather
than rewriting the tests around the gaps:

- context menu never opened on left click: the window dismiss listener
  was armed during the opening click, so it closed immediately
- the All tab is the sentinel all, not a folder name, so an empty
  library claimed a folder was empty
- folder registry was read at module-eval time, before bootstrap
- launch failures only raised a toast, leaving no trace on the row
- copy CDP and DevTools URL actions were missing, and the cdp field had
  been dropped from the process type
- search box had no accessible name and no type=search
- dead search boxes on pages that cannot filter, which also stole Cmd+K
- row actions lost their responsive tier and accessible names

* Restore the consent-gated Launcher updater in the v2 shell

The v2 sidebar reduced updates to a link to the Releases page, dropping
the in-app consent flow: download, signature verification, install and
restart, plus the inline errors for a bad signature or an offline check.
A launcher that runs browser profiles must not swap its own binary
without an explicit click, so port the pill and dialog to the v2 widget
layer instead of trusting the browser.

Three ui-kit fixes this depends on:
- Modal had no accessible name; label it from the title it renders.
- Modal relied on transitionend to unmount, so a closed dialog could
  stay in the accessibility tree; add a timeout fallback.
- ProgressBar dropped aria-label, leaving an unnamed progressbar role.

* Restore startup, MCP readiness and save state in v2 Settings

v2 Settings kept only the two startup switches, so the page no longer
said whether the OS actually registered the entry, which of the four MCP
prerequisites was missing, or whether edits were saved. A switch that
silently fails to register is worse than no switch.

Split the restored surface into StartupCard and McpCard, report each MCP
prerequisite separately, and offer exactly one primary Codex action for
the current state, stating that no config is changed automatically. The
save bar now reports unsaved changes and a pending API restart, and it
is the single Save settings control on the page.

* Stop v2 profile edits dropping data and accepting bad names

toStored rebuilt the document from the fingerprint library payload, so
any field the editor does not render was erased on save. A profile with
custom_fonts lost them by being opened and saved once. Carry the loaded
document through the form and layer the library payload over it.

The backend already owns the name rule via profile_validate_name, but
the v2 frontend never called it, so a rejected name reached profile_save
and the folder assignment. Validate first and show the reason beside the
form rather than in a toast that scrolls away from the field.

* Make v2 keyboard-operable and honest about proxy load state

Select and ProxySelect opened on click only: no combobox role, no Arrow/Enter/Escape handling, so both were unreachable without a mouse. Give them the listbox contract and keyboard navigation, and derive the Cmd/Ctrl hint from detectHostOs instead of the deprecated navigator.platform.

The proxy store latched status on failure, so a failed load could never be retried, and the page rendered neither the loading nor the error state. Make init retryable, bind the storeBus once, and surface loading, error and retry. Scope the empty panel to the filtered list so a search that matches nothing says so instead of claiming there are no proxies.

* Bring the MCP package onto the 2.1.0 version line

The version-parity gate compares the root, MCP, Tauri and Cargo versions. MCP was left on the 0.2.4 line when the custom build moved to 2.1.0, so the gate failed even though every suite passed.

---------

Co-authored-by: Shkret Igor <kritos.biz@gmail.com>
Co-authored-by: ProxyShard <shardxteam@gmail.com>
The upstream merge raised the SDK dependency ranges without regenerating the lockfile, so the release gate's npm ci refused to install. Regenerating it also drops the vulnerable transitive ip-address, clearing the moderate audit gate.
…running profiles (#38)

* Restore the two v2 affordances and stop engine installs from evicting a running browser

The v2 UI rewrite kept the backend commands but dropped the controls that
reached them, so two capabilities were unreachable from the app.

devtools_activate raises the page a profile is sitting on. A site parks a
CAPTCHA or a 2FA prompt on a background tab and the operator never sees it;
without the control the only recourse was hunting for the window by hand. A
failure now says why, because silence here is indistinguishable from a click
that did nothing.

mcp_set_path adopts an MCP server already on disk. The card only offered
Download, so anyone who already had a checkout was pushed into keeping a second
copy and the two drifted apart.

The engine installer was a separate fault, found while reproducing a setup
screen that kept reappearing. Installing deletes the engine tree a running
profile executes from: on Windows the delete fails partway with os error 32 and
leaves a half-installed engine, so the setup screen returns on the next launch
and the operator loses the session. The install now refuses while any profile is
up and names them.

The e2e mock short-circuited every plugin: command to null, which made the
directory picker unmockable and the adopt path untestable; the picker is now
allowed through to the switch.

* Show one MCP section on the settings page instead of two

The upstream merge left two cards headed "MCP server": one reporting readiness
and one offering the download. Reading top to bottom the page states the server
is ready and then, under an identical heading, offers to download it, which
reads as a contradiction rather than two halves of one topic.

Folding the actions into the status card puts the state and the things you can
do about it under a single heading.
The Ctrl/Cmd+K hint sits in a narrow slot beside the search input. It used an
ordinary space, so "Ctrl K" wrapped and rendered as a squashed two-line column.
Use a non-breaking space and whitespace-nowrap.

Adds a regression test that measures every shortcut hint against its own line
height; verified to fail before the fix and pass after.
Bumps the launcher, MCP server, Tauri config, and Cargo crate to 2.1.1 so the
published artefacts and the MCP package report the same version.

Contents since 2.1.0:
- Restore the dropped v2 affordances (devtools_activate, mcp_set_path) (#38)
- Guard engine installs against running profiles, fixing the repeating
  "Setting up ShardX" dialog when a profile holds the engine files (#38)
- Keep the Ctrl/Cmd+K search hint on one line (#39)
The log stopped at 2.0.1, so an operator updating to 2.1.1 saw a "latest"
badge on a release two versions behind and no record of what changed.

2.1.0 covers team enrolment, encrypted profile backup and the pinned engine
manifest. 2.1.1 covers the install guard for running profiles, adopting an
existing MCP folder, raising a verification tab, and the search shortcut
hint fix.

Only additions: the 2.0.1 entries are byte-identical.
Ships the 2.1.0 and 2.1.1 patch log entries, which landed on main after the
2.1.1 tag was cut. Without this release the installed app badges 2.0.1 as the
latest entry and records nothing about the two releases since.

Bumps the launcher, MCP server, Tauri config and Cargo crate together so the
release gate's version parity check passes.
Hermes Desktop is now the MCP host for ShardX. This doc carries the same
operational content as docs/CODEX_SHARDX_HANDOFF.md with host-specific parts
updated: config path, restart instructions, and the startup check command
(hermes mcp test shardbrowser). Version references updated to 2.1.2.

Paths use %USERPROFILE% / %LOCALAPPDATA% rather than a literal home directory
so the doc is not tied to one machine.

The Codex doc is kept, marked superseded, because .omx G0 baseline evidence
records its SHA256.
Hermes Desktop is the MCP host now, so the QA runbook no longer says Codex:
the registration example uses hermes mcp add --command/--args, and the restart
step and prompt goal refer to the MCP host generically.

The filename is unchanged on purpose: .omx G0 baseline evidence hashes this
path, and AGENTS.md-adjacent docs link to it.
Hermes is the MCP host in use now, so it gets its own registration section
before the Codex one, including the differing flag shape: Hermes takes
--command/--args where Codex uses a bare -- separator.

The Codex section stays: src-tauri/src/codex_mcp.rs and the Settings
'Check Codex registration' button are still shipping features, so the docs
must keep matching the UI.
Settings could only inspect Codex, so a machine hosting this MCP server
through Hermes read as unconfigured even when it worked. Add a parallel
hermes_mcp module and surface its result in the same card.

Status is read with `hermes config get mcp_servers.shardbrowser`, whose
resolved output is parsed for the few shapes this entry can take. Reading
the YAML file directly would have pulled a new crate in for one command.

The card keeps its single primary action; the Hermes check and its add
command live under Advanced, so neither host is presented as the only
supported one.
The Settings card reports six distinct states, but the README described
only how to add the entry, leaving no way to act on a reported failure.
Map each state to its fix, and spell out the mismatches the check can
report.

Call out the token-in-config case specifically: the server still runs, so
nothing looks broken, yet the token belongs in the Windows User
environment rather than a file that gets copied or shared.
@anhtahaylove

Copy link
Copy Markdown
Author

Closing — this one is not landable as a PR, and keeping it open is misleading.

It is a main -> main sync of 33 commits (~88k lines) from a fork that has diverged structurally from upstream. Since it was opened, upstream shipped 0d0108b (v2 update (152 Chrome)) plus 8 more commits, which rewrote the frontend into a feature-sliced layout and reworked the MCP setup path. The two histories now share no plausible merge: the fork is 33 ahead / 9 behind, and the conflict surface is the whole tree.

The fork's own line (encrypted profile backup, the v2 control plane, fleet sync, HPKE grants, team server packaging) is effectively a separate product track rather than a change set upstream can absorb wholesale.

If any individual piece is wanted here, it is far better as its own small PR against current main — I am glad to carve one out on request. Meanwhile the focused PRs from this fork are rebased and green:

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