E2EE onboarding: --e2ee decides at first connect, snapshot export for handover - #559
Merged
Conversation
Remote connect had no supported way to select age-v1, so a keyed team still uploaded cipher=none. Make --e2ee the only encryption switch and configure the initial JCS snapshot before engine startup. Keep plain as the default, freeze the choice in the binding, and fail closed on capability mismatch. Expose stable snapshot export for out-of-band provisioning.
An age-selected connection that failed during setup still retained its binding. A manual engine start then synthesized the legacy plaintext default because loadConfig ignored that selection. Treat the binding cipher as authority whenever it is present and refuse to start age-v1 without its authenticated sync configuration.
The binding cipher is now authoritative, so retain a direct regression for the stored-config mismatch path as well as the missing-config downgrade.
A second installation cannot authenticate an encrypted team until the handed key and epoch snapshot are confirmed. Record an observed age-v1 envelope as the binding cipher and leave the engine stopped instead of synthesizing a plaintext configuration. Report the selected cipher at connect time and tell pull users how to import, configure, and reprocess quarantined envelopes.
An encrypted pull now has one fail-closed recovery command. Unlock verifies the handed JCS snapshot and separately confirmed digest, binds the matching identity to its authority key, configures e2ee-required trust, reprocesses quarantine, and starts a verified sync engine. Keep the operation idempotent and document the same secret-handling contract for every generated agent skill.
Count only storage outcomes that actually became readable and refuse to start sync while any blocking quarantine remains. Candidate traversal is diagnostic, not evidence of a successful import. On repeated unlock, stop and observe the prior daemon exit before taking the new log boundary. Attribute readiness to the replacement PID, its pidfile, and its own capability event before reporting success.
The Bats launcher always exports MOCK_PULL_TEAM_ID, using an empty value for ordinary pull tests. Treat that empty value as unset so only the encrypted handoff fixture overrides the canonical team ID.
Update the fail-closed pull assertion to the supported unlock command. Run the unlock CLI roundtrip in the pinned age contract job so the general Bats matrix can remain dependency-light while this path cannot silently skip.
Fail the contract job before Bats when either pinned age binary is unavailable. Guard the PATH wiring and both preflight checks so a skipped unlock test cannot satisfy CI.
The option parser represents age-snapshot as an array because configure accepts a chain. Normalize the verify command to exactly one path before resolving it, and exercise the same array shape used by remote.sh unlock.
The source connect had already retained trust in the shared test root, so the handoff was not exercising a first import. Give the pulling machine its own scripts, storage, keys, and trust root; rejected identities must leave all three unpublished before a successful operator-confirmed import.
Validate peer pidfiles, wait boundedly after TERM, and use KILL only as a diagnosed fallback. Preserve the peer root and fail teardown if process exit cannot be proven, instead of deleting files beneath a live engine.
Use one structural decimal check before signalling teardown PIDs. This rejects zero, leading zeros, non-digits, and oversized spellings without evaluating untrusted pidfile contents as shell arithmetic.
Unlock validates a native age identity file, but key import accepts the raw secret on stdin. Extract the secret line only after the full file has produced the authority-confirmed recipient.
The shared test root was deleted without stopping sync engines. Tests that connected or pulled a team could therefore leave a Node daemon holding the Bats output descriptors after the final assertion. Apply the existing bounded peer cleanup to both install roots on every teardown path, including failed tests. Preserve either root and fail teardown if its engine cannot be stopped.
fujibee
added a commit
that referenced
this pull request
Aug 1, 2026
…ctory The new Windows leg went red on its first run, and both of its assertions had passed: the failure came from teardown, where rm reported "Directory not empty". Signalling the app-server and moving on is enough on POSIX, where an open file does not stop its directory being unlinked. Windows holds the directory while any process inside it is alive, so the removal raced the exit we had only just asked for. Wait for the exit instead, with the reaping wait_for_pid_exit already does. Same discipline as the macOS teardown work in #559, on the platform that actually enforces it.
fujibee
added a commit
that referenced
this pull request
Aug 1, 2026
…567) (#584) * fix(liveness): route every shell-minted pid through the local probe #582 fixed the two probes #567 was reported against. Tracing where each probed pid is minted puts twenty more in the same shape: a pid from $! or $$ in one of these shells, asked about through `tasklist`, which reports Windows pids and has no record of it. Measured on our own Windows runner -- $$, a bash fork, a native binary, one behind an sh launcher, and a pid read back from a pidfile all come back with zero tasklist hits while kill -0 answers yes. Fourteen are the rest of what #505 converted. Eight are older and were found by the same trace: every reader of watch.<id>.pid, which watch.sh writes from its own $$. Those eight are not a codex problem -- they are the monitor path, so on Windows a live watcher reads as dead and a second one starts beside it. delivery.sh already said so in a comment: "we must not re-emit and spawn a duplicate". Five sites keep the generic helper. Their pid comes from the ancestor walk, which leaves the MSYS subsystem, and that is the case the tasklist branch was added for. * fix(codex): keep the bridge pidfile on the tasklist probe Provenance is a property of the writer, not of the file, and this one has two. codex-bridge-launcher.sh:500 writes $! into codex-bridge.<key>.pid; the bridge it just started overwrites that with its own process.pid (codex-bridge.js:896), which on Windows is a native pid. Steady state is therefore the native one, and MSYS kill(1) cannot see it -- so the four readers keep the tasklist-aware helper. Converting them was the same mistake as #505, mirrored. Counting writers across every language rather than only .sh puts the whole tree at four writers over three pidfile families, of which this is the only one with more than one writer. That makes the split 18 converted and 11 held, and 18 + 11 is every call site. #415 and #458 both describe a live Windows bridge read as stale. Neither is addressed here, but the window between the two writes holds an MSYS pid that tasklist reads as dead, which is a candidate for both. * ci(windows): assert the effect on Windows, not the premise #567 shipped through a CI that runs one Windows leg, over the installer. Nothing here has ever decided liveness on Windows, so a change to how liveness is decided on Windows could not have been caught -- and was not. The rest of the coverage for that class stubs `tasklist` on a POSIX host. That shows what the code does when a probe answers "not found"; it does not show that Git Bash answers that way, nor that a launch survives it. These two run with the real tasklist and the real MSYS pid space, and assert what a user would notice: the TUI reaches its bridged --remote handoff, and a bridge is actually started. They are separate failure points, so they are separate tests. Reaching the handoff is what #582 restores; starting the bridge is what it does not, and the difference is why a message still would not arrive. * test(codex): wait for the app-server to exit before removing its directory The new Windows leg went red on its first run, and both of its assertions had passed: the failure came from teardown, where rm reported "Directory not empty". Signalling the app-server and moving on is enough on POSIX, where an open file does not stop its directory being unlinked. Windows holds the directory while any process inside it is alive, so the removal raced the exit we had only just asked for. Wait for the exit instead, with the reaping wait_for_pid_exit already does. Same discipline as the macOS teardown work in #559, on the platform that actually enforces it. * docs(codex): name both liveness helpers where the launcher sources them
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.
Today's E2EE dogfood attempt found the entry point unconnected:
key.sh generaterecorded a key in team config, butconnectestablished a plaintext binding regardless — sealed-looking teams shippedcipher: noneto the server with no warning, and the epoch snapshot needed for the second machine had no export tooling (hand-written JCS was the only path).Rulings implemented (decided live with the operator):
connectis plain by default — key presence changes nothing (the outcome is a function of the arguments, not of incidental prior state). A key that exists but goes unused gets one informational line.--e2eeis the single switch. No key yet → generate inline (backup notice included), then establish the binding age-v1 before the engine starts. Server capabilities without age-v1 → fail closed, never a plaintext fallback.cipher_profileis recorded on the binding;generateon a connected plain team is refused with "create a new team" — a plain remote team cannot become encrypted later, and no reset ceremony is offered (the uploaded plaintext history could not be unpublished anyway).key.sh show --snapshot [--out <file>]exports the epoch snapshot as compact RFC 8785 JCS with its lowercase SHA-256 — the artifact one hands to the second machine, whose explicit import + operator-live confirmation ceremony is unchanged. Initialauthorized_writersis[key_id]: in this minimum, holding the key IS the write authorization, and no other runtime identity exists by design.remote-setup.mdkeeps the plain flow as the body and gains a trailing Extra section; SKILL.md and all nine type templates teach agents the same optional flag.Local green: engine 46, key 24, template/type 19, npm smoke, bash -n, py_compile, diff checks. Loopback-binding bats ride on CI (author's sandbox denies the fixture bind).
PR opened on the author's behalf — their sandbox blocks GitHub access.