Root cause of 'OAuth session expired and could not be refreshed': a host-run
(non-containerized) claude turn read claude's OWN credential store (macOS
Keychain / ~/.claude), not the login the user signed into through Nucleic — so a
stale external login failed. The broker only injected the token for containerized
turns. Now the host spawn sets CLAUDE_CODE_OAUTH_TOKEN from the broker's
freshAccessToken() (refreshed on demand), via a token-provider closure wired from
the app; container turns are unchanged.
Also hide auth artifacts a completed sign-in made stale: the 'Unauthenticated —
log in' row + Log in button and the 'Opening … sign-in in your browser…' note are
dropped once a newer 'sign-in complete' note exists (a later lapse still shows the
prompt). The completion note is kept.
The login affordance never appeared: the ephemeral/scratch transcript passed no
onLogin, and a fatal auth failure arrives as runFinished(.errored) with the reason
in finalText (no .error event) so AuthErrorRow never fired in the main view.
- Wire a backend-aware onLogin into the ephemeral transcript.
- Render AuthErrorRow for a runFinished(.errored) whose finalText looks like an
auth failure, so the main + scratch views both show the button.
- Broaden isAuthError (oauth session expired / failed to authenticate / run /login).
- AppStore.login(forBackend:) routes to Claude or Codex OAuth.
- Settings > Agents: add third 'Unauthenticated' state (orange) with a Log in
button, alongside Connected/Not Connected.
Suppressing the prompt was a band-aid; the root cause is that ClaudeLoginKeychain
read Claude Code's foreign 'Claude Code-credentials' item on every read. Now that
Nucleic mediates its own OAuth, it has no reason to touch another app's credential.
Remove the external read, adoptExternalLoginAfterExplicitLogin, preferredLogin, and
the legacyService constant; read() returns Nucleic's own item only. Nucleic now
reads exclusively credentials it minted, so it can never raise a cross-application
Keychain prompt.
The legacy login-Keychain ACL/partition-list panel ("<app> wants to use
'Claude Code-credentials'…") is not governed by kSecUseAuthenticationUIFail,
so Nucleic Dev.app prompted when reading Claude Code's item. Wrap every legacy
read in SecKeychainSetUserInteractionAllowed(false) so it fails silently instead
of prompting; an already-trusted item still reads without UI. Drop the redundant
(and deprecated) kSecUseAuthenticationUIFail flag.
Stop sandbox/control containers from inheriting ambient ANTHROPIC_API_KEY/
OPENAI_API_KEY/CODEX_API_KEY (which override the mediated OAuth login); keep
the in-Nucleic API-key stores. Scrub the same keys from host_exec subprocesses
via ProcessSpec.removeFromEnvironment. Agent-spawned containers/VMs already
receive no credentials.
The "agent started but produced no output within 60s / stdio transport
stalled" lockup was never in the container stack — it was the approval
server's unix-socket transport. UnixSocketByteConn.close() did
shutdown(2)+close(2), freeing the fd NUMBER while the serve loop could
still dispatch its next blocking read(2) on the stored raw int. The race
arms on every SSE-streamed tools/call (respondStreamingToolCall closes
the connection mid-serve-loop; instant auto-approvals make the window
widest). The straggler read then landed on a RECYCLED descriptor —
another session's MCP socket, a container's stdout vsock channel —
parking a global-queue thread forever on, and stealing bytes from, an
unrelated stream. Zombies accumulated until every session stalled at
once (the 2026-07-14 00:02 wedge: 26 threads parked in receive() with
only 2 live unix connections). Yesterday's container-side fixes were
correct but orthogonal, which is why build 898 still stalled.
Fix: close() now only shuts the socket down (wakes a parked read with
EOF, fails later writes with EPIPE); the descriptor is close(2)d exactly
once, in deinit, and every offloaded read/write block strongly captures
self so the fd number cannot be recycled while any block that could
still pass it to the kernel is in flight. receive() is additionally
cancellation-aware, so stop()'s task cancellation unwedges parked reads
instead of stranding them. Regression test drives ten SSE-close /
fresh-connect cycles over a real AF_UNIX socket.
Also found while validating the earlier patch: ensureInitfs trusted a
bare cached vminit.ext4 forever, so repointing vminitReference at the
custom -nucleic1 image never took effect (containers still boot the
Jun-21 upstream initfs — guest patch #8 never actually deployed). The
cache is now keyed on the reference via a sidecar file, the pull
authenticates against GHCR with the app's GitHub token (the package is
private; anonymous pulls 403), and a failed re-pull falls back to the
existing cache so an offline launch still boots.
Co-Authored-By: Claude Fable 5 <[email protected]>