Skip to content

fix(macos): gate boot on Secure Enclave session — stop TWO FEDERATION IDENTITIES (CIRISServer#380) - #1046

Open
emooreatx wants to merge 1 commit into
mainfrom
fix/macos-se-session-gate
Open

fix(macos): gate boot on Secure Enclave session — stop TWO FEDERATION IDENTITIES (CIRISServer#380)#1046
emooreatx wants to merge 1 commit into
mainfrom
fix/macos-se-session-gate

Conversation

@emooreatx

Copy link
Copy Markdown
Contributor

Problem

On macOS desktop, the backend (which the desktop app launches via main.py --adapter api) fails to initialize:

node fold failed to start: RuntimeError: TWO FEDERATION IDENTITIES IN ONE NODE — refusing to start (CIRISServer#380).
... The persist Engine and this process sign as DIFFERENT keys ...

It works on Linux/CI and iOS, so it read as macOS-specific.

Root cause

The node's one federation identity is sealed in a keystore that is opened more than once per boot — first by the persist Engine (persistence/db/core.py), then by the node compose inside ciris_server.serve_with_python_adapter. On macOS the ciris_keyring factory selects the Secure Enclave signer.

macOS SE key operations return OSStatus -25308 (errSecInteractionNotAllowed) when a console session's screen is locked — but intermittently across the boot's keystore opens. One open lands on SE, the next falls back to software ("Failed to initialize SE wrapper - falling back to software-only"). The two opens then seal the identity as different keys under one alias → the substrate's one-identity gate refuses.

Ruled out with controlled runs:

  • not a path mismatch — identity_dir == get_ciris_home()/identity, byte-identical.
  • not stale keys — reproduces on a fully wiped identity/.
  • Confirmed CGSSessionScreenIsLocked=Yes; caught SE succeeding on one open and -25308-failing on the next within the same boot.

Works everywhere else: Linux/CI has no Secure Enclave (software-only → deterministic); iOS reaches SE reliably (foreground app, unlocked device). Only macOS with a locked console session flip-flops.

Fix

A Secure Enclave session gate (ciris_engine/logic/runtime/se_session_gate.py) runs right before the Engine is constructed. It classifies the macOS console session purely from IOConsoleUsers (via ioreg plist — no PyObjC dep, no fragile SE probe, and it never mints an identity):

Session state Behavior
No console session (headless / CI / ssh) SE consistently unavailable → software is deterministic → proceed
Console session, unlocked SE consistently reachable → proceed (uses SE)
Console session, locked SE intermittent (divergence window) → wait, surfacing Waiting for an active user session to access key material in the Secure Enclave on CLI + logs, polling until unlocked, then proceed on SE

This satisfies all three required modes: headless (software), headed (SE), and headed-first-then-head-away (waits for the session to return instead of minting a divergent second identity). No-op off macOS and on iOS.

Also: node_fold.py now enumerates the identity-dir artifacts before serve(), so any future two-identity refusal names the on-disk keys instead of failing opaquely.

Verification (macOS, 2.9.18)

  • Screen locked → gate holds with the waiting status; zero TWO FEDERATION IDENTITIES; process stays up waiting (no crash).
  • On unlock[SE-GATE] active session detected — resuming startup → node resolves one identity → backend reaches init-complete (status/SETUP, 2.9.172.9.18).
  • 9 unit tests (tests/ciris_engine/logic/runtime/test_se_session_gate.py) cover the classifier + wait/resume + timeout + non-macOS no-op. All pass.

🤖 Generated with Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@emooreatx

Copy link
Copy Markdown
Contributor Author

Strong RCA — the intermittency is the part that explains everything, and deriving the decision from console-session state instead of probing "is this blob SE-backed?" avoids a probe that would itself be flaky. Endorsing the approach. Three findings, one blocking.

Blocking — version collision with #1044

Both PRs bump to 2.9.18-stable:

#1046  +CIRIS_VERSION = "2.9.18-stable"
#1044   CIRIS_VERSION = "2.9.18-stable"

Both target main. Whichever lands second either conflicts in constants.py or ships 2.9.18 twice with different content — which is the exact mismatch class we agreed to stop shipping.

Recommendation: this belongs in 2.9.18 rather than after it. 2.9.18's stated goal is all-platforms login green, and macOS login is currently red, so shipping the release without this would leave the goal unmet. Merge #1044 first (it carries the substrate bump, iOS refresh and the security fixes), then rebase this and drop its constants.py / BUILD_INFO.txt / gradle / Info.plist version edits — 2.9.18 will already be set, so this PR shrinks to the gate itself.

Should fix — the default wait is unbounded, and that is a new permanent hang

db/core.py calls it bare:

await_secure_enclave_session()      # timeout_secs=None → wait forever

Consider a Mac running the agent as a service with a console session logged in but the screen locked — a Mac mini build host or home server, screen locked overnight. That is on_console=True, screen_locked=TrueLOCKEDwaits forever. Today that machine boots intermittently; after this it never boots at all, and the only signal is a log line every 2s.

That converts a flaky failure into a deterministic one, which is the goal — but in the wrong direction for a headed-but-unattended host, and it is the one shape most likely to be running unattended where nobody will see the message.

Your instinct in the caveat was right, and I would go further than optional: give timeout_secs a real default rather than leaving None at the call site. On expiry the existing branch already does the right thing — logs and proceeds, letting the substrate refuse with its own clear error. A bounded wait fails the way it does today; an unbounded one fails a way it never has.

Worth choosing the number against desktop_launcher.py, which has its own health-wait — if the gate outlasts the launcher, the user sees a generic launcher timeout instead of the "screen is locked" message that explains what to do, which loses the whole benefit of the status line.

Should fix — _classify conflates multiple sessions

on_console   = any(u.get("kCGSSessionOnConsoleKey")  for u in users if isinstance(u, dict))
screen_locked = any(u.get("CGSSessionScreenIsLocked") for u in users if isinstance(u, dict))

These are independent any() calls over the array, so the two facts can come from different users. With fast user switching — user A on console and unlocked, user B switched out and locked — this yields on_console=True, screen_locked=TrueLOCKED, and the gate waits even though there is an attended unlocked console session and SE is reachable.

The property that matters is per-user:

reachable = any(
    u.get("kCGSSessionOnConsoleKey") and not u.get("CGSSessionScreenIsLocked")
    for u in users if isinstance(u, dict)
)

test_classify_states parametrizes over (on_console, locked) booleans, so it exercises the classifier's output rather than the users array — the multi-session case cannot fail today. Worth a case that feeds two session dicts.

Minor — log volume while waiting

print + logger.warning every _POLL_SECS = 2.0, indefinitely: ~30 lines/min, ~43k/day on a machine locked overnight. Suggest keeping the 2s poll but backing off the emission (first, then 10s, then 60s) — the poll needs to be responsive so unlock resumes quickly, the message does not.

On your caveat: keep the lock signal

errSecInteractionNotAllowed is definitionally about an attended session, so screen-lock is the causally correct signal here, not a heuristic standing in for one. An SE self-test would be more direct but risks being the very intermittent operation you are trying to make deterministic, and a keygen probe has side effects. I would keep the signal as-is and spend the effort on the bounded timeout instead.

Nice touch on the _multiarch iOS discrimination and on ioreg -a + plistlib over regex — both will age better than the obvious alternatives.

…FEDERATION IDENTITIES)

macOS SE keygen intermittently returns OSStatus -25308 (errSecInteractionNotAllowed)
on a LOCKED console session, so the persist Engine and the node compose seal the
federation identity under different backends (SE vs software) -> two keys -> the
node refuses ("the persist Engine and this process sign as DIFFERENT keys"). Works
elsewhere: Linux/CI has no Secure Enclave (software-only deterministic), iOS reaches
SE reliably (foreground/unlocked). Only macOS with a locked console flip-flops.

Fix: a Secure Enclave session gate (ciris_engine/logic/runtime/se_session_gate.py)
run before the Engine is constructed. Classifies the macOS console session from
IOConsoleUsers (ioreg plist, no PyObjC, no identity mint):
  * no console session (headless/CI): SE unavailable -> software deterministic -> proceed
  * console unlocked: SE reachable -> proceed (uses SE)
  * console LOCKED: SE intermittent -> WAIT with status "Waiting for an active user
    session to access key material in the Secure Enclave" (CLI + KMP console parser),
    polling until unlocked, then proceed on SE.
Covers headless / headed / headed-then-headless. No-op off macOS and on iOS.

Review fixes:
  * bounded wait — timeout_secs now resolves an env default
    (CIRIS_SE_SESSION_GATE_TIMEOUT_SECONDS, default 45s, kept under the desktop
    launcher's 60s health-wait) so an unattended locked host proceeds (today's
    intermittent-boot behavior) instead of hanging forever; <=0 opts into an
    indefinite wait.
  * per-user classify — on-console and screen-locked are read off the SAME
    console user, fixing a split-any() that mislabeled fast-user-switching
    (attended-unlocked A + switched-out-locked B) as LOCKED.

No version bump here — 2.9.18 lands via #1044; this rebases onto it to avoid the
constants.py collision. node_fold.py also enumerates the identity dir before
serve() for diagnosis. 12 unit tests cover classify (incl. user-switching),
wait/resume, timeout bounding, and env parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dwg1ssK93xkJeEZPnQcxg
@emooreatx
emooreatx force-pushed the fix/macos-se-session-gate branch from b98b106 to 334b413 Compare August 16, 2026 05:06
@emooreatx

Copy link
Copy Markdown
Contributor Author

Thanks for the review — all three addressed in 334b413de (force-pushed).

1. Version collision (blocking). Dropped the version bump from this branch entirely — reverted constants.py, Info.plist, androidApp/build.gradle, version.py, desktopApp/build.gradle.kts back to main. The PR is now just the gate + wire-in + tests (+ unavoidable BUILD_INFO/black churn). It targets 2.9.18 via #1044: merge #1044 first, this rebases cleanly with no constants.py overlap.

2. Unbounded wait → real bounded default. timeout_secs now resolves an env-backed default instead of None:

  • CIRIS_SE_SESSION_GATE_TIMEOUT_SECONDS, default 45s — deliberately under desktop_launcher.py's 60s _wait_for_server_health (+~5s post-gate boot), so the "screen is locked" status is what the user sees, not a generic launcher timeout.
  • On expiry the gate returns LOCKED and the caller proceeds → an unattended locked host keeps today's intermittent-boot behavior instead of a permanent hang. <= 0 opts into an explicit indefinite wait.

3. _classify split-any() bug. Fixed — on-console and screen-locked are now read off the same console user, so fast user switching (attended-unlocked A + switched-out-locked B) correctly classifies REACHABLE. Added a regression test with a two-user array (and its mirror).

On the lock signal: kept, per your reasoning — errSecInteractionNotAllowed is definitionally about an attended session, so screen-lock is causally correct. Test count is now 12 (classify incl. user-switching, wait/resume, timeout bounding, env parsing).

And noted re: the valid=false/RuntimeError adjacency — confirmed non-causal (verify failure is swallowed into logger.exception; the abort is the #380 identity guard). The image[0] fallback remains a separate, non-fatal upstream bug.

emooreatx added a commit that referenced this pull request Aug 16, 2026
Bundling CIRISServer#380 (TWO FEDERATION IDENTITIES on macOS) into this release
so 2.9.18 delivers its all-platforms-login goal on macOS as well as Android.

The gate is a proven no-op off macOS — executed on Linux it returns
NOT_APPLICABLE in 0.000s — so it cannot affect the other platforms' boot.

Review findings from #1046 are in: bounded default wait (45s, deliberately under
the launcher's 60s health-wait so 'screen is locked' is the signal rather than a
generic timeout), per-user classify so fast-user-switching reads REACHABLE, and
the version bump dropped since this release owns 2.9.18.
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