From 8ca0e9f0a67bd09d56e49447c530cee6e9905148 Mon Sep 17 00:00:00 2001 From: scgopi Date: Fri, 2 Oct 2026 17:35:15 -0700 Subject: [PATCH] Recognise the Copilot CLI's login in Nod's setup, and store Nod's token where the runtime reads it Beta1's setup screen only knew GitHub's device flow, which needs a GraphCode OAuth client id the build does not carry, so it failed with "This build has no GitHub sign-in configured" on every Mac, including ones already signed in to the Copilot CLI that NodRuntime falls back to. - A Copilot CLI login (Keychain service copilot-cli) counts as signed in, shown as "Using your Copilot CLI sign-in" with nothing to sign out of. - Without a client id the screen says to run copilot login, with Check again, instead of offering a device flow that cannot start. - Nod's own token is stored under github-token, the account NodRuntime/src/credentials.ts reads; github-copilot never reached the engine. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- .../Sources/Clients/NodCredentials.swift | 13 ++++++- .../Features/Settings/Nod/NodSetupModel.swift | 12 ++++++- .../Features/Settings/Nod/NodSetupView.swift | 19 ++++++++-- graphcode/Tests/NodSetupTests.swift | 35 +++++++++++++++++-- 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/graphcode/Sources/Clients/NodCredentials.swift b/graphcode/Sources/Clients/NodCredentials.swift index 3a85587b..bc51da7d 100644 --- a/graphcode/Sources/Clients/NodCredentials.swift +++ b/graphcode/Sources/Clients/NodCredentials.swift @@ -9,7 +9,8 @@ enum NodCredential: String, CaseIterable, Sendable { case anthropicAPIKey = "anthropic-api-key" /// A Claude subscription token. Only stored when `NodClaudeSignIn.subscriptionLoginAllowed`. case claudeSubscription = "claude-subscription" - case githubCopilot = "github-copilot" + /// The account NodRuntime reads (`KeychainAccount.githubToken` in credentials.ts). + case githubCopilot = "github-token" var engine: NodEngine { switch self { @@ -101,6 +102,16 @@ struct NodCredentialStore: Sendable { } } + /// The Copilot CLI's own GitHub login, which NodRuntime's Copilot engine falls back to + /// when Nod holds no token. Attributes only, so it raises no Keychain prompt. Never in + /// the test host, whose sign-in assertions must not depend on this Mac's own login. + static func copilotCLISignInFound() -> Bool { + guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { + return false + } + return NodClaudeSignIn.genericPasswordExists(service: "copilot-cli") + } + func signOut(_ engine: NodEngine) throws { for credential in NodCredential.allCases where credential.engine == engine { try delete(credential) diff --git a/graphcode/Sources/Features/Settings/Nod/NodSetupModel.swift b/graphcode/Sources/Features/Settings/Nod/NodSetupModel.swift index f81361df..123db9e7 100644 --- a/graphcode/Sources/Features/Settings/Nod/NodSetupModel.swift +++ b/graphcode/Sources/Features/Settings/Nod/NodSetupModel.swift @@ -31,6 +31,8 @@ final class NodSetupModel { var copilotPhase: CopilotPhase = .idle private(set) var signedIn: [NodEngine: Bool] = [:] private(set) var claudeCodeSignInFound = false + /// Signed in only through the Copilot CLI's login, which Nod uses but cannot sign out of. + private(set) var usesCopilotCLISignIn = false @ObservationIgnored private let credentials: NodCredentialStore @ObservationIgnored private let deviceFlow: CopilotDeviceFlow @@ -38,6 +40,7 @@ final class NodSetupModel { @ObservationIgnored private let writeSettings: (NodSettings) -> Void @ObservationIgnored private let openURL: (URL) -> Void @ObservationIgnored private var copilotTask: Task? + @ObservationIgnored private let copilotCLISignInFound: () -> Bool init( credentials: NodCredentialStore = .live, @@ -45,8 +48,10 @@ final class NodSetupModel { readSettings: @escaping () -> NodSettings = { SettingsModel.shared.settings.nod }, writeSettings: @escaping (NodSettings) -> Void = { SettingsModel.shared.settings.nod = $0 }, openURL: @escaping (URL) -> Void = { NSWorkspace.shared.open($0) }, - claudeCodeSignInFound: () -> Bool = { NodClaudeSignIn.claudeCodeSignInFound() } + claudeCodeSignInFound: () -> Bool = { NodClaudeSignIn.claudeCodeSignInFound() }, + copilotCLISignInFound: @escaping () -> Bool = { NodCredentialStore.copilotCLISignInFound() } ) { + self.copilotCLISignInFound = copilotCLISignInFound self.credentials = credentials self.deviceFlow = deviceFlow self.readSettings = readSettings @@ -59,6 +64,9 @@ final class NodSetupModel { func isSignedIn(_ engine: NodEngine) -> Bool { signedIn[engine] ?? false } + /// Whether this build can run GitHub's device flow — it needs GraphCode's OAuth app id. + var canStartDeviceFlow: Bool { deviceFlow.clientID != nil } + func selectEngine(_ engine: NodEngine) { settings.switchEngine(to: engine) } @@ -67,6 +75,8 @@ final class NodSetupModel { for engine in NodEngine.allCases { signedIn[engine] = credentials.isSignedIn(engine) } + usesCopilotCLISignIn = !isSignedIn(.copilotSDK) && copilotCLISignInFound() + if usesCopilotCLISignIn { signedIn[.copilotSDK] = true } if isSignedIn(.copilotSDK), copilotPhase == .idle { copilotPhase = .signedIn(nil) } diff --git a/graphcode/Sources/Features/Settings/Nod/NodSetupView.swift b/graphcode/Sources/Features/Settings/Nod/NodSetupView.swift index b1119fc6..d41e686b 100644 --- a/graphcode/Sources/Features/Settings/Nod/NodSetupView.swift +++ b/graphcode/Sources/Features/Settings/Nod/NodSetupView.swift @@ -178,9 +178,15 @@ struct NodCopilotSignInCard: View { var body: some View { VStack(alignment: .leading, spacing: 10) { switch model.copilotPhase { - case .idle: + case .idle where model.canStartDeviceFlow: Button("Sign in to GitHub") { model.startCopilotSignIn() } .buttonStyle(.borderedProminent) + case .idle: + Text("Sign in with the Copilot CLI: run copilot login in a terminal, then check again.") + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.75)) + .textSelection(.enabled) + Button("Check again") { model.refreshSignIn() } case .requesting: HStack(spacing: 8) { ProgressView().controlSize(.small) @@ -242,7 +248,12 @@ struct NodCopilotSignInCard: View { .foregroundStyle(NodSetupInk.ok) VStack(alignment: .leading, spacing: 3) { Text( - [account.map { "Signed in as \($0.login)" } ?? "Signed in to GitHub", account?.planName] + model.usesCopilotCLISignIn + ? "Using your Copilot CLI sign-in" + : [ + account.map { "Signed in as \($0.login)" } ?? "Signed in to GitHub", + account?.planName, + ] .compactMap { $0 }.joined(separator: " · ") ) .font(.system(size: 12.5, weight: .medium)) @@ -253,7 +264,9 @@ struct NodCopilotSignInCard: View { } } Spacer(minLength: 4) - Button("Sign out") { model.signOut(.copilotSDK) } + if !model.usesCopilotCLISignIn { + Button("Sign out") { model.signOut(.copilotSDK) } + } } .padding(10) .background(.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 8)) diff --git a/graphcode/Tests/NodSetupTests.swift b/graphcode/Tests/NodSetupTests.swift index 583a44f8..366101a6 100644 --- a/graphcode/Tests/NodSetupTests.swift +++ b/graphcode/Tests/NodSetupTests.swift @@ -468,12 +468,14 @@ import Testing static func model( _ box: Box, credentials: NodCredentialStore = .inMemory(), - flow: CopilotDeviceFlow = CopilotDeviceFlow(clientID: nil, transport: { _ in (Data(), 500) }) + flow: CopilotDeviceFlow = CopilotDeviceFlow(clientID: nil, transport: { _ in (Data(), 500) }), + copilotCLISignInFound: @escaping () -> Bool = { false } ) -> NodSetupModel { NodSetupModel( credentials: credentials, deviceFlow: flow, readSettings: { box.settings }, writeSettings: { box.settings = $0 }, - openURL: { box.opened.append($0) }, claudeCodeSignInFound: { true }) + openURL: { box.opened.append($0) }, claudeCodeSignInFound: { true }, + copilotCLISignInFound: copilotCLISignInFound) } @Test func savingAValidKeySignsClaudeIn() throws { @@ -528,6 +530,35 @@ import Testing #expect(!model.isSignedIn(.copilotSDK)) } + /// NodRuntime's Copilot engine falls back to the Copilot CLI's own login, so a Mac + /// signed in there is signed in for Nod, with nothing of Nod's to sign out of. + @Test func theCopilotCLILoginSignsCopilotIn() { + var found = false + let model = Self.model(Box(), copilotCLISignInFound: { found }) + #expect(!model.isSignedIn(.copilotSDK)) + #expect(!model.canStartDeviceFlow) + + found = true + model.refreshSignIn() + + #expect(model.isSignedIn(.copilotSDK)) + #expect(model.usesCopilotCLISignIn) + #expect(model.copilotPhase == .signedIn(nil)) + } + + @Test func nodsOwnTokenWinsOverTheCopilotCLILogin() { + let model = Self.model( + Box(), credentials: .inMemory([.githubCopilot: "gho_x"]), copilotCLISignInFound: { true }) + #expect(model.isSignedIn(.copilotSDK)) + #expect(!model.usesCopilotCLISignIn) + } + + /// The runtime reads `github-token` (NodRuntime/src/credentials.ts); a token written under + /// any other account never reaches the Copilot engine. + @Test func theCopilotTokenIsStoredWhereTheRuntimeReadsIt() { + #expect(NodCredential.githubCopilot.rawValue == "github-token") + } + @Test func anUnconfiguredBuildSaysSo() async { let model = Self.model(Box()) model.startCopilotSignIn()