Skip to content

E2EE onboarding: --e2ee decides at first connect, snapshot export for handover - #559

Merged
fujibee merged 15 commits into
integration/remotefrom
feat/e2ee-onboarding
Jul 30, 2026
Merged

E2EE onboarding: --e2ee decides at first connect, snapshot export for handover#559
fujibee merged 15 commits into
integration/remotefrom
feat/e2ee-onboarding

Conversation

@fujibee

@fujibee fujibee commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Today's E2EE dogfood attempt found the entry point unconnected: key.sh generate recorded a key in team config, but connect established a plaintext binding regardless — sealed-looking teams shipped cipher: none to 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):

  • The encryption decision happens exactly once, at first connect, by explicit flag. connect is 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.
  • --e2ee is 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.
  • The choice is immutable. cipher_profile is recorded on the binding; generate on 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. Initial authorized_writers is [key_id]: in this minimum, holding the key IS the write authorization, and no other runtime identity exists by design.
  • Docs: E2EE stays extra in OSS. remote-setup.md keeps 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.

fujibee added 15 commits July 29, 2026 19:48
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
fujibee merged commit 985be32 into integration/remote Jul 30, 2026
30 of 34 checks passed
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
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