Merge nucleic/brisk-thistle-falcon-sazd into dev

Add a dedicated GitHub sign-in to Settings > Git, so connecting an account
no longer requires hand-minting a personal access token.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-02 07:31:39 +00:00
co-authored by Claude Opus 5
3 changed files with 284 additions and 164 deletions
+230 -161
View File
@@ -1599,13 +1599,19 @@ private struct MacVMSettingsTab: View {
}
}
/// The Git tab: Nucleic's Managed Git credential and where it's used. The credential is always
/// used for Nucleic's own host-side git (cloning a project, pushing a branch, opening a PR) and is
/// optionally made available to sandboxed sessions. The SSH config section re-supplies any
/// connectivity Managed Git's hermetic ssh would otherwise miss.
/// The Git tab, in the order a user meets it: connect a GitHub account (one button, no secrets to
/// mint), then the Managed Git credential and where it's used, then the SSH config that re-supplies
/// any connectivity Managed Git's hermetic ssh would otherwise miss. The credential is always used
/// for Nucleic's own host-side git (cloning a project, pushing a branch, opening a PR) and is
/// optionally made available to sandboxed sessions.
///
/// GitHub leads deliberately: signing in is the answer for most people, and it configures Managed
/// Git for them. Managed Git stays its own section for everyone who'd rather supply the exact
/// credential themselves a PAT, their own SSH key, or a Nucleic-minted one.
private struct GitSettingsTab: View {
var body: some View {
Form {
GitHubSignInSection()
GitHubAccessSection()
ManagedGitSSHConfigSection()
}
@@ -1613,6 +1619,204 @@ private struct GitSettingsTab: View {
}
}
/// Put a value on the general pasteboard. Shared by the Git sections the sign-in code in one, the
/// Nucleic-managed public key in the other.
private func copyToPasteboard(_ value: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
/// Connecting a GitHub account, as its own concern: one button, a browser round-trip, done. This is
/// the front door it exists so the common case never has to understand Managed Git's modes or hand-
/// craft a personal access token. What it produces *is* an ordinary Managed Git token (same Keychain
/// slot, same provisioning), so signing in here simply configures the section below.
///
/// Kept separate from `GitHubAccessSection` because the two answer different questions: this one is
/// "which GitHub account is Nucleic acting as", that one is "which credential and transport does git
/// use". A user who signs in should never need to open the second.
private struct GitHubSignInSection: View {
@AppStorage(GitHubCredentialSettings.authModeKey) private var modeRaw = GitHubAuthMode.none.rawValue
/// The GitHub OAuth App client id browser sign-in runs against, when the user supplies their own.
/// Public (the device flow has no secret), so it's an ordinary preference see `GitHubOAuth`.
@AppStorage(GitHubOAuth.clientIDKey) private var oauthClientID = ""
/// Where browser sign-in is: idle, asking GitHub for a code, or showing the code and polling.
@State private var phase: Phase = .idle
/// A failure from the sign-in flow, shown inline under the button.
@State private var error: String?
/// The in-flight sign-in, so Cancel can stop the poll loop.
@State private var task: Task<Void, Never>?
/// Whether the token Nucleic holds came from a sign-in here (rather than a pasted PAT). Backed by
/// defaults rather than local state so `GitHubAccessSection` below which renders the same
/// Keychain slot stays in step the instant this changes.
@AppStorage(GitHubOAuth.signedInKey) private var signedIn = false
private enum Phase: Equatable {
case idle
/// Waiting on the device-code request (a single round-trip, so usually a blink).
case requesting
/// GitHub issued a code; showing it while polling for the user's approval.
case awaiting(GitHubOAuth.DeviceCode)
}
/// The OAuth App client id sign-in will use: the user's override, else whatever's baked into the
/// build, else `nil` (sign-in unconfigured). Resolved through `@AppStorage` rather than
/// `GitHubOAuth.clientID` so the UI updates live as they paste one in.
private var resolvedClientID: String? {
let override = oauthClientID.trimmingCharacters(in: .whitespacesAndNewlines)
if !override.isEmpty { return override }
return GitHubOAuth.builtInClientID.isEmpty ? nil : GitHubOAuth.builtInClientID
}
var body: some View {
Section("GitHub") {
if signedIn {
signedInRow
} else {
signInControls
}
if let error {
Label(error, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
// Only in the way when sign-in has nothing to run against.
if resolvedClientID == nil { clientIDEntry }
}
// Reconcile the flag against the store: a token cleared from under it (an applied credential
// bundle, a Keychain reset) must not leave this claiming an account that isn't there.
.onAppear { signedIn = GitHubOAuth.isSignedIn }
}
@ViewBuilder private var signedInRow: some View {
HStack {
Label("Signed in to GitHub", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
Spacer()
Button("Sign out", role: .destructive) { signOut() }
}
Text("Nucleic holds the token GitHub issued. It authenticates `git` over HTTPS while "
+ "Authentication below is set to Token, and `gh`'s API calls in every mode. Signing out "
+ "discards it.")
.font(.caption)
.foregroundStyle(.secondary)
}
/// Browser sign-in via GitHub's device flow (``GitHubOAuth``): Nucleic shows the short user code,
/// opens GitHub's approval page with it pre-filled, and polls until the user approves then
/// stores the issued token in the same Keychain slot a pasted PAT uses.
@ViewBuilder private var signInControls: some View {
switch phase {
case .idle:
Button { startSignIn() } label: {
Label("Sign in with GitHub", systemImage: "person.badge.key.fill")
}
.disabled(resolvedClientID == nil)
Text("Opens your browser to approve Nucleic, then stores the token GitHub issues — no "
+ "personal access token to create by hand. Prefer to supply your own credential? "
+ "Use Managed Git below.")
.font(.caption)
.foregroundStyle(.secondary)
case .requesting:
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Contacting GitHub…").foregroundStyle(.secondary)
Spacer()
Button("Cancel") { cancelSignIn() }
}
case .awaiting(let device):
VStack(alignment: .leading, spacing: 6) {
Text("Approve the sign-in in your browser. Nucleic opened GitHub with this code "
+ "filled in, and copied it to your clipboard in case you need to paste it:")
HStack(spacing: 8) {
Text(device.userCode)
.font(.system(.title3, design: .monospaced).weight(.semibold))
.textSelection(.enabled)
Button("Copy") { copyToPasteboard(device.userCode) }
Button("Open GitHub") { openVerificationPage(device) }
Spacer()
ProgressView().controlSize(.small)
Button("Cancel") { cancelSignIn() }
}
}
}
}
/// GitHub publishes no shared client id the way Anthropic and OpenAI do for their CLIs, so
/// sign-in needs an OAuth App. A client id is public (the device flow carries no secret), which
/// is why this is a plain text field rather than a Keychain-backed one.
@ViewBuilder private var clientIDEntry: some View {
LabeledContent("OAuth App client ID") {
TextField("Ov23li…", text: $oauthClientID)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 220)
}
Text("Sign-in uses GitHub's device flow. Register an OAuth App at "
+ "github.com/settings/developers with “Enable Device Flow” turned on, then paste its "
+ "client ID here.")
.font(.caption)
.foregroundStyle(.secondary)
}
private func startSignIn() {
guard let clientID = resolvedClientID else { return }
error = nil
phase = .requesting
task?.cancel()
task = Task {
let oauth = GitHubOAuth(clientID: clientID)
do {
let device = try await oauth.requestDeviceCode()
phase = .awaiting(device)
// Put the code on the clipboard before opening the page: GitHub pre-fills it from the
// link, but if anything drops the parameter the user's next instinct paste works
// without them having to reach back to this window first.
copyToPasteboard(device.userCode)
openVerificationPage(device)
finishSignIn(token: try await oauth.pollForToken(device))
} catch is CancellationError {
phase = .idle
} catch {
phase = .idle
let detail = (error as? GitHubOAuth.OAuthError)?.description
?? error.localizedDescription
self.error = "Sign-in failed: \(detail)"
}
}
}
/// Store the freshly issued token. A user who signed in while no credential was configured
/// clearly wants Nucleic talking to GitHub, so select token mode for them; a user already on an
/// SSH or Nucleic-managed key keeps that transport and just gains the API token `gh` wants.
private func finishSignIn(token: String) {
GitHubCredentialStore.saveToken(token)
if GitHubAuthMode(rawValue: modeRaw) ?? .none == .none {
modeRaw = GitHubAuthMode.token.rawValue
}
phase = .idle
error = nil
signedIn = true
}
private func signOut() {
cancelSignIn()
GitHubCredentialStore.deleteToken()
signedIn = false
error = nil
}
private func cancelSignIn() {
task?.cancel()
task = nil
phase = .idle
}
private func openVerificationPage(_ device: GitHubOAuth.DeviceCode) {
guard let url = device.verificationURL else { return }
NSWorkspace.shared.open(url)
}
}
/// Extra OpenSSH config for Managed Git's connections to GitHub. Managed Git deliberately ignores
/// the user's real `~/.ssh/config` (to stay off their personal keys and ssh-agent), which also drops
/// any connectivity directives there so this section lets the user re-supply just the connectivity
@@ -1652,9 +1856,10 @@ private struct GitHubAccessSection: View {
/// commit against the account whose signing key the managed key is).
@AppStorage(GitHubCredentialSettings.managedCommitNameKey) private var commitName = ""
@AppStorage(GitHubCredentialSettings.managedCommitEmailKey) private var commitEmail = ""
/// The GitHub OAuth App client id browser sign-in runs against, when the user supplies their own.
/// Public (the device flow has no secret), so it's an ordinary preference see `GitHubOAuth`.
@AppStorage(GitHubOAuth.clientIDKey) private var oauthClientID = ""
/// Whether the stored token came from the GitHub section's sign-in. Shared with that section
/// through defaults, so this one shows "Signed in to GitHub" instead of offering to clear a
/// credential the user never pasted.
@AppStorage(GitHubOAuth.signedInKey) private var signedIn = false
/// Draft input for the secret being entered; never pre-filled from the Keychain (we don't
/// read secrets back into the UI). `tokenStored` / `keyStored` reflect whether one is saved.
@@ -1675,32 +1880,8 @@ private struct GitHubAccessSection: View {
/// A failure from generating the managed key.
@State private var managedError: String?
/// Where browser sign-in is: idle, asking GitHub for a code, or showing the code and polling.
@State private var signInPhase: SignInPhase = .idle
/// A failure from the sign-in flow, shown inline under the button.
@State private var signInError: String?
/// The in-flight sign-in, so Cancel can stop the poll loop.
@State private var signInTask: Task<Void, Never>?
private enum SignInPhase: Equatable {
case idle
/// Waiting on the device-code request (a single round-trip, so usually a blink).
case requesting
/// GitHub issued a code; showing it while polling for the user's approval.
case awaiting(GitHubOAuth.DeviceCode)
}
private var mode: GitHubAuthMode { GitHubAuthMode(rawValue: modeRaw) ?? .none }
/// The OAuth App client id sign-in will use: the user's override, else whatever's baked into the
/// build, else `nil` (sign-in unconfigured). Resolved through `@AppStorage` rather than
/// `GitHubOAuth.clientID` so the UI updates live as they paste one in.
private var resolvedClientID: String? {
let override = oauthClientID.trimmingCharacters(in: .whitespacesAndNewlines)
if !override.isEmpty { return override }
return GitHubOAuth.builtInClientID.isEmpty ? nil : GitHubOAuth.builtInClientID
}
/// True wherever Nucleic Control runs which is everywhere the container service exists, since
/// Control is on for every project. Control sessions run in the shared container, which has no
/// other way to reach GitHub, so the credential is always made available to them; the toggle is
@@ -1718,7 +1899,11 @@ private struct GitHubAccessSection: View {
switch mode {
case .none:
noCredentialSignIn
// Nothing to configure the GitHub section above is where a user without a
// credential is meant to start.
Text("Nucleic reaches only public repositories until a credential is configured.")
.font(.caption)
.foregroundStyle(.secondary)
case .token:
tokenControls
case .ssh:
@@ -1749,123 +1934,6 @@ private struct GitHubAccessSection: View {
}
}
// MARK: Sign in with GitHub
/// What `.none` mode offers: the one-click path, so a user who just wants Nucleic to reach their
/// private repos never has to learn what a PAT is. Signing in from here also selects token mode
/// (see `finishSignIn`); the pickers above stay for anyone who'd rather supply their own secret.
@ViewBuilder private var noCredentialSignIn: some View {
gitHubSignIn
Text("Without a credential Nucleic reaches only public repositories. Signing in with GitHub "
+ "sets up token authentication for you — or pick a mode above to supply a token or SSH "
+ "key yourself.")
.font(.caption)
.foregroundStyle(.secondary)
}
/// Browser sign-in via GitHub's device flow (``GitHubOAuth``): Nucleic shows the short user code,
/// opens GitHub's approval page, and polls until the user approves then stores the issued token
/// in the same Keychain slot a pasted PAT uses, so nothing downstream can tell the difference.
@ViewBuilder private var gitHubSignIn: some View {
switch signInPhase {
case .idle:
Button { startSignIn() } label: {
Label("Sign in with GitHub", systemImage: "person.badge.key.fill")
}
.disabled(resolvedClientID == nil)
case .requesting:
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Contacting GitHub…").foregroundStyle(.secondary)
Spacer()
Button("Cancel") { cancelSignIn() }
}
case .awaiting(let device):
VStack(alignment: .leading, spacing: 6) {
Text("Enter this code on GitHub to finish signing in:")
HStack(spacing: 8) {
Text(device.userCode)
.font(.system(.title3, design: .monospaced).weight(.semibold))
.textSelection(.enabled)
Button("Copy") { copyToPasteboard(device.userCode) }
Button("Open GitHub") { openVerificationPage(device) }
Spacer()
ProgressView().controlSize(.small)
Button("Cancel") { cancelSignIn() }
}
}
}
if let signInError {
Label(signInError, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
// Only in the way when sign-in has nothing to run against.
if resolvedClientID == nil { clientIDEntry }
}
/// GitHub publishes no shared client id the way Anthropic and OpenAI do for their CLIs, so
/// sign-in needs an OAuth App. A client id is public (the device flow carries no secret), which
/// is why this is a plain text field rather than a Keychain-backed one.
@ViewBuilder private var clientIDEntry: some View {
LabeledContent("OAuth App client ID") {
TextField("Ov23li…", text: $oauthClientID)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 220)
}
Text("Sign-in uses GitHub's device flow. Register an OAuth App at "
+ "github.com/settings/developers with “Enable Device Flow” turned on, then paste its "
+ "client ID here.")
.font(.caption)
.foregroundStyle(.secondary)
}
private func startSignIn() {
guard let clientID = resolvedClientID else { return }
signInError = nil
signInPhase = .requesting
signInTask?.cancel()
signInTask = Task {
let oauth = GitHubOAuth(clientID: clientID)
do {
let device = try await oauth.requestDeviceCode()
signInPhase = .awaiting(device)
openVerificationPage(device)
finishSignIn(token: try await oauth.pollForToken(device))
} catch is CancellationError {
signInPhase = .idle
} catch {
signInPhase = .idle
let detail = (error as? GitHubOAuth.OAuthError)?.description
?? error.localizedDescription
signInError = "Sign-in failed: \(detail)"
}
}
}
/// Store the freshly issued token. A user who signed in from `.none` clearly wants Nucleic
/// talking to GitHub, so select token mode for them; a user already on an SSH or Nucleic-managed
/// key keeps that transport and just gains the API token `gh` wants (the same slot the SSH modes'
/// optional token field fills).
private func finishSignIn(token: String) {
GitHubCredentialStore.saveToken(token)
if mode == .none { modeRaw = GitHubAuthMode.token.rawValue }
signInPhase = .idle
signInError = nil
refreshStoredFlags()
}
private func cancelSignIn() {
signInTask?.cancel()
signInTask = nil
signInPhase = .idle
}
private func openVerificationPage(_ device: GitHubOAuth.DeviceCode) {
guard let url = device.verificationURL else { return }
NSWorkspace.shared.open(url)
}
// MARK: Token
@ViewBuilder private var tokenControls: some View {
@@ -1873,22 +1941,28 @@ private struct GitHubAccessSection: View {
}
/// Save/clear UI for the stored PAT. Backed by the one Keychain token slot, so the same secret
/// serves token mode (git + gh) and SSH mode (gh API) and is where browser sign-in deposits
/// the token it obtains, which is why signing in is offered right alongside pasting one.
/// serves token mode (git + gh) and SSH mode (gh API) and it's the slot the GitHub section's
/// sign-in fills, so this reads "Signed in to GitHub" when that's where the token came from
/// rather than offering to clear a credential the user never pasted.
@ViewBuilder private func tokenEntry() -> some View {
if tokenStored {
if signedIn {
Label("Signed in to GitHub", systemImage: "checkmark.seal.fill")
.foregroundStyle(.green)
Text("Using the token from the GitHub section above. Sign out there to replace it.")
.font(.caption)
.foregroundStyle(.secondary)
} else if tokenStored {
storedRow(label: "Token saved") {
GitHubCredentialStore.deleteToken()
refreshStoredFlags()
}
} else {
gitHubSignIn
Text("or paste a personal access token")
.font(.caption)
.foregroundStyle(.secondary)
SecureField("Personal access token", text: $tokenDraft)
Button("Save token") {
GitHubCredentialStore.saveToken(tokenDraft.trimmingCharacters(in: .whitespacesAndNewlines))
// A hand-pasted token supersedes whatever sign-in left behind, so the GitHub section
// must stop claiming an account connection for it.
signedIn = false
tokenDraft = ""
refreshStoredFlags()
}
@@ -2060,11 +2134,6 @@ private struct GitHubAccessSection: View {
}
}
private func copyToPasteboard(_ value: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
// MARK: Shared SSH options (apply to both .ssh and .managed)
@ViewBuilder private var sshOptions: some View {
+33 -2
View File
@@ -47,6 +47,23 @@ public struct GitHubOAuth: Sendable {
return resolved.isEmpty ? nil : resolved
}
/// Whether the token in ``GitHubCredentialStore`` came from a browser sign-in rather than being
/// pasted in by hand. Both land in the same Keychain slot and behave identically this only
/// lets Settings say "Signed in to GitHub" honestly instead of claiming an account connection
/// for a PAT the user minted themselves. Deliberately *not* part of ``GitCredentialBundle``: a
/// runner never signs in interactively, so forwarding it would only churn the mesh payload.
public static let signedInKey = "nucleic.github.signedInViaOAuth"
/// True when a browser sign-in issued the token Nucleic currently holds. The flag alone isn't
/// trusted this re-checks the store, so a token cleared out from under it (an applied
/// credential bundle, a Keychain reset) reads as signed out rather than leaving Settings
/// asserting an account connection that no longer exists. Settings reconciles the stored flag
/// against this on appear.
public static var isSignedIn: Bool {
UserDefaults.standard.bool(forKey: signedInKey)
&& !(GitHubCredentialStore.loadToken() ?? "").isEmpty
}
/// Where Nucleic requests the device/user code pair.
public static let deviceCodeEndpoint = "https://github.com/login/device/code"
/// Where Nucleic polls for the access token once the user has approved.
@@ -141,8 +158,22 @@ public struct GitHubOAuth: Sendable {
self.interval = interval
}
/// The verification page as a `URL`, for opening in the browser.
public var verificationURL: URL? { URL(string: verificationURI) }
/// The verification page to open, with the user code appended as a `user_code` query
/// parameter so GitHub can pre-fill it and the user doesn't have to retype it.
///
/// GitHub does **not** return RFC 8628's `verification_uri_complete` (verified against the
/// live endpoint: the response carries only the five fields above), so Nucleic composes the
/// link itself. GitHub carries the parameter through its own sign-in redirect a logged-out
/// user gets bounced to `/login?return_to=user_code%3D` and lands back here with it intact
/// so it survives the worst case of the user not being signed in yet. Should GitHub ever
/// ignore the parameter, the page just renders its empty code box: exactly what the user
/// would have seen without it, with the code still on screen and on their clipboard.
public var verificationURL: URL? {
guard var components = URLComponents(string: verificationURI) else { return nil }
components.queryItems = (components.queryItems ?? [])
+ [URLQueryItem(name: "user_code", value: userCode)]
return components.url
}
}
/// Step 1: ask GitHub for a device/user code pair. The caller then shows `userCode` and opens
+21 -1
View File
@@ -57,7 +57,27 @@ struct GitHubOAuthTests {
#expect(device.verificationURI == "https://github.com/login/device")
#expect(device.expiresIn == 899)
#expect(device.interval == 5)
#expect(device.verificationURL?.absoluteString == "https://github.com/login/device")
}
/// GitHub returns no `verification_uri_complete`, so Nucleic appends the user code itself the
/// difference between the user retyping `WDJB-MJHT` and the page arriving with it filled in.
@Test func verificationURLCarriesTheUserCode() throws {
let device = try #require(GitHubOAuth.deviceCode(from: Data(Self.deviceCodeBody.utf8)))
#expect(
device.verificationURL?.absoluteString
== "https://github.com/login/device?user_code=WDJB-MJHT")
}
/// A `verification_uri` that already has a query keeps it the code is appended, not swapped in.
@Test func verificationURLPreservesAnExistingQuery() throws {
let body = """
{"device_code":"dc","user_code":"AAAA-BBBB",\
"verification_uri":"https://github.example.com/login/device?flow=x"}
"""
let device = try #require(GitHubOAuth.deviceCode(from: Data(body.utf8)))
#expect(
device.verificationURL?.absoluteString
== "https://github.example.com/login/device?flow=x&user_code=AAAA-BBBB")
}
@Test func deviceCodeDefaultsIntervalAndVerificationURI() throws {