mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:08:37 +00:00
refactor(macos): remove anthropic oauth onboarding flow
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
|
- macOS/Onboarding: remove Anthropic OAuth sign-in from the Mac onboarding UI and keep Anthropic subscription auth setup-token-only (legacy `oauth.json` OAuth onboarding path removed).
|
||||||
- Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus.
|
- Android/Chat: improve streaming delivery handling and markdown rendering quality in the native Android chat UI, including better GitHub-flavored markdown behavior. (#26079) Thanks @obviyus.
|
||||||
- Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures.
|
- Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures.
|
||||||
- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
|
- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import Combine
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
struct AnthropicAuthControls: View {
|
|
||||||
let connectionMode: AppState.ConnectionMode
|
|
||||||
|
|
||||||
@State private var oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
|
|
||||||
@State private var pkce: AnthropicOAuth.PKCE?
|
|
||||||
@State private var code: String = ""
|
|
||||||
@State private var busy = false
|
|
||||||
@State private var statusText: String?
|
|
||||||
@State private var autoDetectClipboard = true
|
|
||||||
@State private var autoConnectClipboard = true
|
|
||||||
@State private var lastPasteboardChangeCount = NSPasteboard.general.changeCount
|
|
||||||
|
|
||||||
private static let clipboardPoll: AnyPublisher<Date, Never> = {
|
|
||||||
if ProcessInfo.processInfo.isRunningTests {
|
|
||||||
return Empty(completeImmediately: false).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
return Timer.publish(every: 0.4, on: .main, in: .common)
|
|
||||||
.autoconnect()
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}()
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
if self.connectionMode != .local {
|
|
||||||
Text("Gateway isn’t running locally; OAuth must be created on the gateway host.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
Circle()
|
|
||||||
.fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
|
|
||||||
.frame(width: 8, height: 8)
|
|
||||||
Text(self.oauthStatus.shortDescription)
|
|
||||||
.font(.footnote.weight(.semibold))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
|
||||||
Button("Reveal") {
|
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()])
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(!FileManager().fileExists(atPath: OpenClawOAuthStore.oauthURL().path))
|
|
||||||
|
|
||||||
Button("Refresh") {
|
|
||||||
self.refresh()
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(OpenClawOAuthStore.oauthURL().path)
|
|
||||||
.font(.caption.monospaced())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.middle)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button {
|
|
||||||
self.startOAuth()
|
|
||||||
} label: {
|
|
||||||
if self.busy {
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
} else {
|
|
||||||
Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.connectionMode != .local || self.busy)
|
|
||||||
|
|
||||||
if self.pkce != nil {
|
|
||||||
Button("Cancel") {
|
|
||||||
self.pkce = nil
|
|
||||||
self.code = ""
|
|
||||||
self.statusText = nil
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(self.busy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.pkce != nil {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Paste `code#state`")
|
|
||||||
.font(.footnote.weight(.semibold))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
|
|
||||||
TextField("code#state", text: self.$code)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.disabled(self.busy)
|
|
||||||
|
|
||||||
Toggle("Auto-detect from clipboard", isOn: self.$autoDetectClipboard)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.disabled(self.busy)
|
|
||||||
|
|
||||||
Toggle("Auto-connect when detected", isOn: self.$autoConnectClipboard)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.disabled(self.busy)
|
|
||||||
|
|
||||||
Button("Connect") {
|
|
||||||
Task { await self.finishOAuth() }
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(self.busy || self.connectionMode != .local || self.code
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let statusText, !statusText.isEmpty {
|
|
||||||
Text(statusText)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
self.refresh()
|
|
||||||
}
|
|
||||||
.onReceive(Self.clipboardPoll) { _ in
|
|
||||||
self.pollClipboardIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refresh() {
|
|
||||||
let imported = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
|
||||||
self.oauthStatus = OpenClawOAuthStore.anthropicOAuthStatus()
|
|
||||||
if imported != nil {
|
|
||||||
self.statusText = "Imported existing OAuth credentials."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startOAuth() {
|
|
||||||
guard self.connectionMode == .local else { return }
|
|
||||||
guard !self.busy else { return }
|
|
||||||
self.busy = true
|
|
||||||
defer { self.busy = false }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let pkce = try AnthropicOAuth.generatePKCE()
|
|
||||||
self.pkce = pkce
|
|
||||||
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
self.statusText = "Browser opened. After approving, paste the `code#state` value here."
|
|
||||||
} catch {
|
|
||||||
self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func finishOAuth() async {
|
|
||||||
guard self.connectionMode == .local else { return }
|
|
||||||
guard !self.busy else { return }
|
|
||||||
guard let pkce = self.pkce else { return }
|
|
||||||
self.busy = true
|
|
||||||
defer { self.busy = false }
|
|
||||||
|
|
||||||
guard let parsed = AnthropicOAuthCodeState.parse(from: self.code) else {
|
|
||||||
self.statusText = "OAuth failed: missing or invalid code/state."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let creds = try await AnthropicOAuth.exchangeCode(
|
|
||||||
code: parsed.code,
|
|
||||||
state: parsed.state,
|
|
||||||
verifier: pkce.verifier)
|
|
||||||
try OpenClawOAuthStore.saveAnthropicOAuth(creds)
|
|
||||||
self.refresh()
|
|
||||||
self.pkce = nil
|
|
||||||
self.code = ""
|
|
||||||
self.statusText = "Connected. OpenClaw can now use Claude via OAuth."
|
|
||||||
} catch {
|
|
||||||
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pollClipboardIfNeeded() {
|
|
||||||
guard self.connectionMode == .local else { return }
|
|
||||||
guard self.pkce != nil else { return }
|
|
||||||
guard !self.busy else { return }
|
|
||||||
guard self.autoDetectClipboard else { return }
|
|
||||||
|
|
||||||
let pb = NSPasteboard.general
|
|
||||||
let changeCount = pb.changeCount
|
|
||||||
guard changeCount != self.lastPasteboardChangeCount else { return }
|
|
||||||
self.lastPasteboardChangeCount = changeCount
|
|
||||||
|
|
||||||
guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
|
|
||||||
guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
|
|
||||||
guard let pkce = self.pkce, parsed.state == pkce.verifier else { return }
|
|
||||||
|
|
||||||
let next = "\(parsed.code)#\(parsed.state)"
|
|
||||||
if self.code != next {
|
|
||||||
self.code = next
|
|
||||||
self.statusText = "Detected `code#state` from clipboard."
|
|
||||||
}
|
|
||||||
|
|
||||||
guard self.autoConnectClipboard else { return }
|
|
||||||
Task { await self.finishOAuth() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
extension AnthropicAuthControls {
|
|
||||||
init(
|
|
||||||
connectionMode: AppState.ConnectionMode,
|
|
||||||
oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus,
|
|
||||||
pkce: AnthropicOAuth.PKCE? = nil,
|
|
||||||
code: String = "",
|
|
||||||
busy: Bool = false,
|
|
||||||
statusText: String? = nil,
|
|
||||||
autoDetectClipboard: Bool = true,
|
|
||||||
autoConnectClipboard: Bool = true)
|
|
||||||
{
|
|
||||||
self.connectionMode = connectionMode
|
|
||||||
self._oauthStatus = State(initialValue: oauthStatus)
|
|
||||||
self._pkce = State(initialValue: pkce)
|
|
||||||
self._code = State(initialValue: code)
|
|
||||||
self._busy = State(initialValue: busy)
|
|
||||||
self._statusText = State(initialValue: statusText)
|
|
||||||
self._autoDetectClipboard = State(initialValue: autoDetectClipboard)
|
|
||||||
self._autoConnectClipboard = State(initialValue: autoConnectClipboard)
|
|
||||||
self._lastPasteboardChangeCount = State(initialValue: NSPasteboard.general.changeCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
import CryptoKit
|
|
||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
import Security
|
|
||||||
|
|
||||||
struct AnthropicOAuthCredentials: Codable {
|
|
||||||
let type: String
|
|
||||||
let refresh: String
|
|
||||||
let access: String
|
|
||||||
let expires: Int64
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AnthropicAuthMode: Equatable {
|
|
||||||
case oauthFile
|
|
||||||
case oauthEnv
|
|
||||||
case apiKeyEnv
|
|
||||||
case missing
|
|
||||||
|
|
||||||
var shortLabel: String {
|
|
||||||
switch self {
|
|
||||||
case .oauthFile: "OAuth (OpenClaw token file)"
|
|
||||||
case .oauthEnv: "OAuth (env var)"
|
|
||||||
case .apiKeyEnv: "API key (env var)"
|
|
||||||
case .missing: "Missing credentials"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isConfigured: Bool {
|
|
||||||
switch self {
|
|
||||||
case .missing: false
|
|
||||||
case .oauthFile, .oauthEnv, .apiKeyEnv: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AnthropicAuthResolver {
|
|
||||||
static func resolve(
|
|
||||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
||||||
oauthStatus: OpenClawOAuthStore.AnthropicOAuthStatus = OpenClawOAuthStore
|
|
||||||
.anthropicOAuthStatus()) -> AnthropicAuthMode
|
|
||||||
{
|
|
||||||
if oauthStatus.isConnected { return .oauthFile }
|
|
||||||
|
|
||||||
if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!token.isEmpty
|
|
||||||
{
|
|
||||||
return .oauthEnv
|
|
||||||
}
|
|
||||||
|
|
||||||
if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!key.isEmpty
|
|
||||||
{
|
|
||||||
return .apiKeyEnv
|
|
||||||
}
|
|
||||||
|
|
||||||
return .missing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AnthropicOAuth {
|
|
||||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "anthropic-oauth")
|
|
||||||
|
|
||||||
private static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
||||||
private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")!
|
|
||||||
private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")!
|
|
||||||
private static let redirectURI = "https://console.anthropic.com/oauth/code/callback"
|
|
||||||
private static let scopes = "org:create_api_key user:profile user:inference"
|
|
||||||
|
|
||||||
struct PKCE {
|
|
||||||
let verifier: String
|
|
||||||
let challenge: String
|
|
||||||
}
|
|
||||||
|
|
||||||
static func generatePKCE() throws -> PKCE {
|
|
||||||
var bytes = [UInt8](repeating: 0, count: 32)
|
|
||||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
|
||||||
guard status == errSecSuccess else {
|
|
||||||
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
|
||||||
}
|
|
||||||
let verifier = Data(bytes).base64URLEncodedString()
|
|
||||||
let hash = SHA256.hash(data: Data(verifier.utf8))
|
|
||||||
let challenge = Data(hash).base64URLEncodedString()
|
|
||||||
return PKCE(verifier: verifier, challenge: challenge)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func buildAuthorizeURL(pkce: PKCE) -> URL {
|
|
||||||
var components = URLComponents(url: self.authorizeURL, resolvingAgainstBaseURL: false)!
|
|
||||||
components.queryItems = [
|
|
||||||
URLQueryItem(name: "code", value: "true"),
|
|
||||||
URLQueryItem(name: "client_id", value: self.clientId),
|
|
||||||
URLQueryItem(name: "response_type", value: "code"),
|
|
||||||
URLQueryItem(name: "redirect_uri", value: self.redirectURI),
|
|
||||||
URLQueryItem(name: "scope", value: self.scopes),
|
|
||||||
URLQueryItem(name: "code_challenge", value: pkce.challenge),
|
|
||||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
|
||||||
// Match legacy flow: state is the verifier.
|
|
||||||
URLQueryItem(name: "state", value: pkce.verifier),
|
|
||||||
]
|
|
||||||
return components.url!
|
|
||||||
}
|
|
||||||
|
|
||||||
static func exchangeCode(
|
|
||||||
code: String,
|
|
||||||
state: String,
|
|
||||||
verifier: String) async throws -> AnthropicOAuthCredentials
|
|
||||||
{
|
|
||||||
let payload: [String: Any] = [
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"client_id": self.clientId,
|
|
||||||
"code": code,
|
|
||||||
"state": state,
|
|
||||||
"redirect_uri": self.redirectURI,
|
|
||||||
"code_verifier": verifier,
|
|
||||||
]
|
|
||||||
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
|
||||||
|
|
||||||
var request = URLRequest(url: self.tokenURL)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.httpBody = body
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
guard let http = response as? HTTPURLResponse else {
|
|
||||||
throw URLError(.badServerResponse)
|
|
||||||
}
|
|
||||||
guard (200..<300).contains(http.statusCode) else {
|
|
||||||
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
|
||||||
throw NSError(
|
|
||||||
domain: "AnthropicOAuth",
|
|
||||||
code: http.statusCode,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Token exchange failed: \(text)"])
|
|
||||||
}
|
|
||||||
|
|
||||||
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
||||||
let access = decoded?["access_token"] as? String
|
|
||||||
let refresh = decoded?["refresh_token"] as? String
|
|
||||||
let expiresIn = decoded?["expires_in"] as? Double
|
|
||||||
guard let access, let refresh, let expiresIn else {
|
|
||||||
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "Unexpected token response.",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
|
|
||||||
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
+ Int64(expiresIn * 1000)
|
|
||||||
- Int64(5 * 60 * 1000)
|
|
||||||
|
|
||||||
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
|
||||||
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials {
|
|
||||||
let payload: [String: Any] = [
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"client_id": self.clientId,
|
|
||||||
"refresh_token": refreshToken,
|
|
||||||
]
|
|
||||||
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
|
|
||||||
|
|
||||||
var request = URLRequest(url: self.tokenURL)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.httpBody = body
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
guard let http = response as? HTTPURLResponse else {
|
|
||||||
throw URLError(.badServerResponse)
|
|
||||||
}
|
|
||||||
guard (200..<300).contains(http.statusCode) else {
|
|
||||||
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
|
|
||||||
throw NSError(
|
|
||||||
domain: "AnthropicOAuth",
|
|
||||||
code: http.statusCode,
|
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"])
|
|
||||||
}
|
|
||||||
|
|
||||||
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
||||||
let access = decoded?["access_token"] as? String
|
|
||||||
let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken
|
|
||||||
let expiresIn = decoded?["expires_in"] as? Double
|
|
||||||
guard let access, let expiresIn else {
|
|
||||||
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "Unexpected token response.",
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
+ Int64(expiresIn * 1000)
|
|
||||||
- Int64(5 * 60 * 1000)
|
|
||||||
|
|
||||||
self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
|
||||||
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum OpenClawOAuthStore {
|
|
||||||
static let oauthFilename = "oauth.json"
|
|
||||||
private static let providerKey = "anthropic"
|
|
||||||
private static let openclawOAuthDirEnv = "OPENCLAW_OAUTH_DIR"
|
|
||||||
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
|
|
||||||
|
|
||||||
enum AnthropicOAuthStatus: Equatable {
|
|
||||||
case missingFile
|
|
||||||
case unreadableFile
|
|
||||||
case invalidJSON
|
|
||||||
case missingProviderEntry
|
|
||||||
case missingTokens
|
|
||||||
case connected(expiresAtMs: Int64?)
|
|
||||||
|
|
||||||
var isConnected: Bool {
|
|
||||||
if case .connected = self { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var shortDescription: String {
|
|
||||||
switch self {
|
|
||||||
case .missingFile: "OpenClaw OAuth token file not found"
|
|
||||||
case .unreadableFile: "OpenClaw OAuth token file not readable"
|
|
||||||
case .invalidJSON: "OpenClaw OAuth token file invalid"
|
|
||||||
case .missingProviderEntry: "No Anthropic entry in OpenClaw OAuth token file"
|
|
||||||
case .missingTokens: "Anthropic entry missing tokens"
|
|
||||||
case .connected: "OpenClaw OAuth credentials found"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func oauthDir() -> URL {
|
|
||||||
if let override = ProcessInfo.processInfo.environment[self.openclawOAuthDirEnv]?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!override.isEmpty
|
|
||||||
{
|
|
||||||
let expanded = NSString(string: override).expandingTildeInPath
|
|
||||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
|
||||||
}
|
|
||||||
let home = FileManager().homeDirectoryForCurrentUser
|
|
||||||
return home.appendingPathComponent(".openclaw", isDirectory: true)
|
|
||||||
.appendingPathComponent("credentials", isDirectory: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func oauthURL() -> URL {
|
|
||||||
self.oauthDir().appendingPathComponent(self.oauthFilename)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func legacyOAuthURLs() -> [URL] {
|
|
||||||
var urls: [URL] = []
|
|
||||||
let env = ProcessInfo.processInfo.environment
|
|
||||||
if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!override.isEmpty
|
|
||||||
{
|
|
||||||
let expanded = NSString(string: override).expandingTildeInPath
|
|
||||||
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
|
|
||||||
}
|
|
||||||
|
|
||||||
let home = FileManager().homeDirectoryForCurrentUser
|
|
||||||
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
|
|
||||||
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
|
|
||||||
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
|
|
||||||
urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
|
|
||||||
|
|
||||||
var seen = Set<String>()
|
|
||||||
return urls.filter { url in
|
|
||||||
let path = url.standardizedFileURL.path
|
|
||||||
if seen.contains(path) { return false }
|
|
||||||
seen.insert(path)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
|
|
||||||
let dest = self.oauthURL()
|
|
||||||
guard !FileManager().fileExists(atPath: dest.path) else { return nil }
|
|
||||||
|
|
||||||
for url in self.legacyOAuthURLs() {
|
|
||||||
guard FileManager().fileExists(atPath: url.path) else { continue }
|
|
||||||
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
|
|
||||||
guard let storage = self.loadStorage(at: url) else { continue }
|
|
||||||
do {
|
|
||||||
try self.saveStorage(storage)
|
|
||||||
return url
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
|
|
||||||
self.anthropicOAuthStatus(at: self.oauthURL())
|
|
||||||
}
|
|
||||||
|
|
||||||
static func hasAnthropicOAuth() -> Bool {
|
|
||||||
self.anthropicOAuthStatus().isConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
static func anthropicOAuthStatus(at url: URL) -> AnthropicOAuthStatus {
|
|
||||||
guard FileManager().fileExists(atPath: url.path) else { return .missingFile }
|
|
||||||
|
|
||||||
guard let data = try? Data(contentsOf: url) else { return .unreadableFile }
|
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return .invalidJSON }
|
|
||||||
guard let storage = json as? [String: Any] else { return .invalidJSON }
|
|
||||||
guard let rawEntry = storage[self.providerKey] else { return .missingProviderEntry }
|
|
||||||
guard let entry = rawEntry as? [String: Any] else { return .invalidJSON }
|
|
||||||
|
|
||||||
let refresh = self.firstString(in: entry, keys: ["refresh", "refresh_token", "refreshToken"])
|
|
||||||
let access = self.firstString(in: entry, keys: ["access", "access_token", "accessToken"])
|
|
||||||
guard refresh?.isEmpty == false, access?.isEmpty == false else { return .missingTokens }
|
|
||||||
|
|
||||||
let expiresAny = entry["expires"] ?? entry["expires_at"] ?? entry["expiresAt"]
|
|
||||||
let expiresAtMs: Int64? = if let ms = expiresAny as? Int64 {
|
|
||||||
ms
|
|
||||||
} else if let number = expiresAny as? NSNumber {
|
|
||||||
number.int64Value
|
|
||||||
} else if let ms = expiresAny as? Double {
|
|
||||||
Int64(ms)
|
|
||||||
} else {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return .connected(expiresAtMs: expiresAtMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func loadAnthropicOAuthRefreshToken() -> String? {
|
|
||||||
let url = self.oauthURL()
|
|
||||||
guard let storage = self.loadStorage(at: url) else { return nil }
|
|
||||||
guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil }
|
|
||||||
let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"])
|
|
||||||
return refresh?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
|
|
||||||
for key in keys {
|
|
||||||
if let value = dict[key] as? String { return value }
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func loadStorage(at url: URL) -> [String: Any]? {
|
|
||||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
|
|
||||||
return json as? [String: Any]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
|
|
||||||
let url = self.oauthURL()
|
|
||||||
let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
|
|
||||||
|
|
||||||
var updated = existing
|
|
||||||
updated[self.providerKey] = [
|
|
||||||
"type": creds.type,
|
|
||||||
"refresh": creds.refresh,
|
|
||||||
"access": creds.access,
|
|
||||||
"expires": creds.expires,
|
|
||||||
]
|
|
||||||
|
|
||||||
try self.saveStorage(updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func saveStorage(_ storage: [String: Any]) throws {
|
|
||||||
let dir = self.oauthDir()
|
|
||||||
try FileManager().createDirectory(
|
|
||||||
at: dir,
|
|
||||||
withIntermediateDirectories: true,
|
|
||||||
attributes: [.posixPermissions: 0o700])
|
|
||||||
|
|
||||||
let url = self.oauthURL()
|
|
||||||
let data = try JSONSerialization.data(
|
|
||||||
withJSONObject: storage,
|
|
||||||
options: [.prettyPrinted, .sortedKeys])
|
|
||||||
try data.write(to: url, options: [.atomic])
|
|
||||||
try FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Data {
|
|
||||||
fileprivate func base64URLEncodedString() -> String {
|
|
||||||
self.base64EncodedString()
|
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
|
||||||
.replacingOccurrences(of: "/", with: "_")
|
|
||||||
.replacingOccurrences(of: "=", with: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum AnthropicOAuthCodeState {
|
|
||||||
struct Parsed: Equatable {
|
|
||||||
let code: String
|
|
||||||
let state: String
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extracts a `code#state` payload from arbitrary text.
|
|
||||||
///
|
|
||||||
/// Supports:
|
|
||||||
/// - raw `code#state`
|
|
||||||
/// - OAuth callback URLs containing `code=` and `state=` query params
|
|
||||||
/// - surrounding text/backticks from instructions pages
|
|
||||||
static func extract(from raw: String) -> String? {
|
|
||||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.trimmingCharacters(in: CharacterSet(charactersIn: "`"))
|
|
||||||
if text.isEmpty { return nil }
|
|
||||||
|
|
||||||
if let fromURL = self.extractFromURL(text) { return fromURL }
|
|
||||||
if let fromToken = self.extractFromToken(text) { return fromToken }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func parse(from raw: String) -> Parsed? {
|
|
||||||
guard let extracted = self.extract(from: raw) else { return nil }
|
|
||||||
let parts = extracted.split(separator: "#", maxSplits: 1).map(String.init)
|
|
||||||
let code = parts.first ?? ""
|
|
||||||
let state = parts.count > 1 ? parts[1] : ""
|
|
||||||
guard !code.isEmpty, !state.isEmpty else { return nil }
|
|
||||||
return Parsed(code: code, state: state)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func extractFromURL(_ text: String) -> String? {
|
|
||||||
// Users might copy the callback URL from the browser address bar.
|
|
||||||
guard let components = URLComponents(string: text),
|
|
||||||
let items = components.queryItems,
|
|
||||||
let code = items.first(where: { $0.name == "code" })?.value,
|
|
||||||
let state = items.first(where: { $0.name == "state" })?.value,
|
|
||||||
!code.isEmpty, !state.isEmpty
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
return "\(code)#\(state)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func extractFromToken(_ text: String) -> String? {
|
|
||||||
// Base64url-ish tokens; keep this fairly strict to avoid false positives.
|
|
||||||
let pattern = #"([A-Za-z0-9._~-]{8,})#([A-Za-z0-9._~-]{8,})"#
|
|
||||||
guard let re = try? NSRegularExpression(pattern: pattern) else { return nil }
|
|
||||||
|
|
||||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
|
||||||
guard let match = re.firstMatch(in: text, range: range),
|
|
||||||
match.numberOfRanges == 3,
|
|
||||||
let full = Range(match.range(at: 0), in: text)
|
|
||||||
else { return nil }
|
|
||||||
|
|
||||||
return String(text[full])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import Combine
|
|
||||||
import Observation
|
import Observation
|
||||||
import OpenClawChatUI
|
import OpenClawChatUI
|
||||||
import OpenClawDiscovery
|
import OpenClawDiscovery
|
||||||
@@ -69,22 +68,6 @@ struct OnboardingView: View {
|
|||||||
@State var workspacePath: String = ""
|
@State var workspacePath: String = ""
|
||||||
@State var workspaceStatus: String?
|
@State var workspaceStatus: String?
|
||||||
@State var workspaceApplying = false
|
@State var workspaceApplying = false
|
||||||
@State var anthropicAuthPKCE: AnthropicOAuth.PKCE?
|
|
||||||
@State var anthropicAuthCode: String = ""
|
|
||||||
@State var anthropicAuthStatus: String?
|
|
||||||
@State var anthropicAuthBusy = false
|
|
||||||
@State var anthropicAuthConnected = false
|
|
||||||
@State var anthropicAuthVerifying = false
|
|
||||||
@State var anthropicAuthVerified = false
|
|
||||||
@State var anthropicAuthVerificationAttempted = false
|
|
||||||
@State var anthropicAuthVerificationFailed = false
|
|
||||||
@State var anthropicAuthVerifiedAt: Date?
|
|
||||||
@State var anthropicAuthDetectedStatus: OpenClawOAuthStore.AnthropicOAuthStatus = .missingFile
|
|
||||||
@State var anthropicAuthAutoDetectClipboard = true
|
|
||||||
@State var anthropicAuthAutoConnectClipboard = true
|
|
||||||
@State var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
|
|
||||||
@State var monitoringAuth = false
|
|
||||||
@State var authMonitorTask: Task<Void, Never>?
|
|
||||||
@State var needsBootstrap = false
|
@State var needsBootstrap = false
|
||||||
@State var didAutoKickoff = false
|
@State var didAutoKickoff = false
|
||||||
@State var showAdvancedConnection = false
|
@State var showAdvancedConnection = false
|
||||||
@@ -104,19 +87,9 @@ struct OnboardingView: View {
|
|||||||
let pageWidth: CGFloat = Self.windowWidth
|
let pageWidth: CGFloat = Self.windowWidth
|
||||||
let contentHeight: CGFloat = 460
|
let contentHeight: CGFloat = 460
|
||||||
let connectionPageIndex = 1
|
let connectionPageIndex = 1
|
||||||
let anthropicAuthPageIndex = 2
|
|
||||||
let wizardPageIndex = 3
|
let wizardPageIndex = 3
|
||||||
let onboardingChatPageIndex = 8
|
let onboardingChatPageIndex = 8
|
||||||
|
|
||||||
static let clipboardPoll: AnyPublisher<Date, Never> = {
|
|
||||||
if ProcessInfo.processInfo.isRunningTests {
|
|
||||||
return Empty(completeImmediately: false).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
return Timer.publish(every: 0.4, on: .main, in: .common)
|
|
||||||
.autoconnect()
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
}()
|
|
||||||
|
|
||||||
let permissionsPageIndex = 5
|
let permissionsPageIndex = 5
|
||||||
static func pageOrder(
|
static func pageOrder(
|
||||||
for mode: AppState.ConnectionMode,
|
for mode: AppState.ConnectionMode,
|
||||||
|
|||||||
@@ -78,70 +78,4 @@ extension OnboardingView {
|
|||||||
self.copied = true
|
self.copied = true
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false }
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
func startAnthropicOAuth() {
|
|
||||||
guard !self.anthropicAuthBusy else { return }
|
|
||||||
self.anthropicAuthBusy = true
|
|
||||||
defer { self.anthropicAuthBusy = false }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let pkce = try AnthropicOAuth.generatePKCE()
|
|
||||||
self.anthropicAuthPKCE = pkce
|
|
||||||
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
|
||||||
NSWorkspace.shared.open(url)
|
|
||||||
self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here."
|
|
||||||
} catch {
|
|
||||||
self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func finishAnthropicOAuth() async {
|
|
||||||
guard !self.anthropicAuthBusy else { return }
|
|
||||||
guard let pkce = self.anthropicAuthPKCE else { return }
|
|
||||||
self.anthropicAuthBusy = true
|
|
||||||
defer { self.anthropicAuthBusy = false }
|
|
||||||
|
|
||||||
guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else {
|
|
||||||
self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let creds = try await AnthropicOAuth.exchangeCode(
|
|
||||||
code: parsed.code,
|
|
||||||
state: parsed.state,
|
|
||||||
verifier: pkce.verifier)
|
|
||||||
try OpenClawOAuthStore.saveAnthropicOAuth(creds)
|
|
||||||
self.refreshAnthropicOAuthStatus()
|
|
||||||
self.anthropicAuthStatus = "Connected. OpenClaw can now use Claude."
|
|
||||||
} catch {
|
|
||||||
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pollAnthropicClipboardIfNeeded() {
|
|
||||||
guard self.currentPage == self.anthropicAuthPageIndex else { return }
|
|
||||||
guard self.anthropicAuthPKCE != nil else { return }
|
|
||||||
guard !self.anthropicAuthBusy else { return }
|
|
||||||
guard self.anthropicAuthAutoDetectClipboard else { return }
|
|
||||||
|
|
||||||
let pb = NSPasteboard.general
|
|
||||||
let changeCount = pb.changeCount
|
|
||||||
guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return }
|
|
||||||
self.anthropicAuthLastPasteboardChangeCount = changeCount
|
|
||||||
|
|
||||||
guard let raw = pb.string(forType: .string), !raw.isEmpty else { return }
|
|
||||||
guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return }
|
|
||||||
guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return }
|
|
||||||
|
|
||||||
let next = "\(parsed.code)#\(parsed.state)"
|
|
||||||
if self.anthropicAuthCode != next {
|
|
||||||
self.anthropicAuthCode = next
|
|
||||||
self.anthropicAuthStatus = "Detected `code#state` from clipboard."
|
|
||||||
}
|
|
||||||
|
|
||||||
guard self.anthropicAuthAutoConnectClipboard else { return }
|
|
||||||
Task { await self.finishAnthropicOAuth() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ extension OnboardingView {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
self.stopPermissionMonitoring()
|
self.stopPermissionMonitoring()
|
||||||
self.stopDiscovery()
|
self.stopDiscovery()
|
||||||
self.stopAuthMonitoring()
|
|
||||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
@@ -61,7 +60,6 @@ extension OnboardingView {
|
|||||||
self.refreshCLIStatus()
|
self.refreshCLIStatus()
|
||||||
await self.loadWorkspaceDefaults()
|
await self.loadWorkspaceDefaults()
|
||||||
await self.ensureDefaultWorkspace()
|
await self.ensureDefaultWorkspace()
|
||||||
self.refreshAnthropicOAuthStatus()
|
|
||||||
self.refreshBootstrapStatus()
|
self.refreshBootstrapStatus()
|
||||||
self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID()
|
self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ extension OnboardingView {
|
|||||||
func updateMonitoring(for pageIndex: Int) {
|
func updateMonitoring(for pageIndex: Int) {
|
||||||
self.updatePermissionMonitoring(for: pageIndex)
|
self.updatePermissionMonitoring(for: pageIndex)
|
||||||
self.updateDiscoveryMonitoring(for: pageIndex)
|
self.updateDiscoveryMonitoring(for: pageIndex)
|
||||||
self.updateAuthMonitoring(for: pageIndex)
|
|
||||||
self.maybeKickoffOnboardingChat(for: pageIndex)
|
self.maybeKickoffOnboardingChat(for: pageIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,33 +62,6 @@ extension OnboardingView {
|
|||||||
self.gatewayDiscovery.stop()
|
self.gatewayDiscovery.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAuthMonitoring(for pageIndex: Int) {
|
|
||||||
let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local
|
|
||||||
if shouldMonitor, !self.monitoringAuth {
|
|
||||||
self.monitoringAuth = true
|
|
||||||
self.startAuthMonitoring()
|
|
||||||
} else if !shouldMonitor, self.monitoringAuth {
|
|
||||||
self.stopAuthMonitoring()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func startAuthMonitoring() {
|
|
||||||
self.refreshAnthropicOAuthStatus()
|
|
||||||
self.authMonitorTask?.cancel()
|
|
||||||
self.authMonitorTask = Task {
|
|
||||||
while !Task.isCancelled {
|
|
||||||
await MainActor.run { self.refreshAnthropicOAuthStatus() }
|
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopAuthMonitoring() {
|
|
||||||
self.monitoringAuth = false
|
|
||||||
self.authMonitorTask?.cancel()
|
|
||||||
self.authMonitorTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func installCLI() async {
|
func installCLI() async {
|
||||||
guard !self.installingCLI else { return }
|
guard !self.installingCLI else { return }
|
||||||
self.installingCLI = true
|
self.installingCLI = true
|
||||||
@@ -125,54 +97,4 @@ extension OnboardingView {
|
|||||||
expected: expected)
|
expected: expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshAnthropicOAuthStatus() {
|
|
||||||
_ = OpenClawOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
|
||||||
let previous = self.anthropicAuthDetectedStatus
|
|
||||||
let status = OpenClawOAuthStore.anthropicOAuthStatus()
|
|
||||||
self.anthropicAuthDetectedStatus = status
|
|
||||||
self.anthropicAuthConnected = status.isConnected
|
|
||||||
|
|
||||||
if previous != status {
|
|
||||||
self.anthropicAuthVerified = false
|
|
||||||
self.anthropicAuthVerificationAttempted = false
|
|
||||||
self.anthropicAuthVerificationFailed = false
|
|
||||||
self.anthropicAuthVerifiedAt = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func verifyAnthropicOAuthIfNeeded(force: Bool = false) async {
|
|
||||||
guard self.state.connectionMode == .local else { return }
|
|
||||||
guard self.anthropicAuthDetectedStatus.isConnected else { return }
|
|
||||||
if self.anthropicAuthVerified, !force { return }
|
|
||||||
if self.anthropicAuthVerifying { return }
|
|
||||||
if self.anthropicAuthVerificationAttempted, !force { return }
|
|
||||||
|
|
||||||
self.anthropicAuthVerificationAttempted = true
|
|
||||||
self.anthropicAuthVerifying = true
|
|
||||||
self.anthropicAuthVerificationFailed = false
|
|
||||||
defer { self.anthropicAuthVerifying = false }
|
|
||||||
|
|
||||||
guard let refresh = OpenClawOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else {
|
|
||||||
self.anthropicAuthStatus = "OAuth verification failed: missing refresh token."
|
|
||||||
self.anthropicAuthVerificationFailed = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let updated = try await AnthropicOAuth.refresh(refreshToken: refresh)
|
|
||||||
try OpenClawOAuthStore.saveAnthropicOAuth(updated)
|
|
||||||
self.refreshAnthropicOAuthStatus()
|
|
||||||
self.anthropicAuthVerified = true
|
|
||||||
self.anthropicAuthVerifiedAt = Date()
|
|
||||||
self.anthropicAuthVerificationFailed = false
|
|
||||||
self.anthropicAuthStatus = "OAuth detected and verified."
|
|
||||||
} catch {
|
|
||||||
self.anthropicAuthVerified = false
|
|
||||||
self.anthropicAuthVerifiedAt = nil
|
|
||||||
self.anthropicAuthVerificationFailed = true
|
|
||||||
self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ extension OnboardingView {
|
|||||||
self.welcomePage()
|
self.welcomePage()
|
||||||
case 1:
|
case 1:
|
||||||
self.connectionPage()
|
self.connectionPage()
|
||||||
case 2:
|
|
||||||
self.anthropicAuthPage()
|
|
||||||
case 3:
|
case 3:
|
||||||
self.wizardPage()
|
self.wizardPage()
|
||||||
case 5:
|
case 5:
|
||||||
@@ -340,170 +338,6 @@ extension OnboardingView {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
func anthropicAuthPage() -> some View {
|
|
||||||
self.onboardingPage {
|
|
||||||
Text("Connect Claude")
|
|
||||||
.font(.largeTitle.weight(.semibold))
|
|
||||||
Text("Give your model the token it needs!")
|
|
||||||
.font(.body)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.frame(maxWidth: 540)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
Text("OpenClaw supports any model — we strongly recommend Opus 4.6 for the best experience.")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.frame(maxWidth: 540)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
self.onboardingCard(spacing: 12, padding: 16) {
|
|
||||||
HStack(alignment: .center, spacing: 10) {
|
|
||||||
Circle()
|
|
||||||
.fill(self.anthropicAuthVerified ? Color.green : Color.orange)
|
|
||||||
.frame(width: 10, height: 10)
|
|
||||||
Text(
|
|
||||||
self.anthropicAuthConnected
|
|
||||||
? (self.anthropicAuthVerified
|
|
||||||
? "Claude connected (OAuth) — verified"
|
|
||||||
: "Claude connected (OAuth)")
|
|
||||||
: "Not connected yet")
|
|
||||||
.font(.headline)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.anthropicAuthConnected, self.anthropicAuthVerifying {
|
|
||||||
Text("Verifying OAuth…")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
} else if !self.anthropicAuthConnected {
|
|
||||||
Text(self.anthropicAuthDetectedStatus.shortDescription)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
} else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt {
|
|
||||||
Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"This lets OpenClaw use Claude immediately. Credentials are stored at " +
|
|
||||||
"`~/.openclaw/credentials/oauth.json` (owner-only).")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Text(OpenClawOAuthStore.oauthURL().path)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.middle)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button("Reveal") {
|
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([OpenClawOAuthStore.oauthURL()])
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
|
|
||||||
Button("Refresh") {
|
|
||||||
self.refreshAnthropicOAuthStatus()
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider().padding(.vertical, 2)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
if !self.anthropicAuthVerified {
|
|
||||||
if self.anthropicAuthConnected {
|
|
||||||
Button("Verify") {
|
|
||||||
Task { await self.verifyAnthropicOAuthIfNeeded(force: true) }
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
|
|
||||||
|
|
||||||
if self.anthropicAuthVerificationFailed {
|
|
||||||
Button("Re-auth (OAuth)") {
|
|
||||||
self.startAnthropicOAuth()
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Button {
|
|
||||||
self.startAnthropicOAuth()
|
|
||||||
} label: {
|
|
||||||
if self.anthropicAuthBusy {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Text("Open Claude sign-in (OAuth)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.disabled(self.anthropicAuthBusy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Paste the `code#state` value")
|
|
||||||
.font(.headline)
|
|
||||||
TextField("code#state", text: self.$anthropicAuthCode)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
|
|
||||||
Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.disabled(self.anthropicAuthBusy)
|
|
||||||
|
|
||||||
Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.disabled(self.anthropicAuthBusy)
|
|
||||||
|
|
||||||
Button("Connect") {
|
|
||||||
Task { await self.finishAnthropicOAuth() }
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.disabled(
|
|
||||||
self.anthropicAuthBusy ||
|
|
||||||
self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
||||||
}
|
|
||||||
.onReceive(Self.clipboardPoll) { _ in
|
|
||||||
self.pollAnthropicClipboardIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.onboardingCard(spacing: 8, padding: 12) {
|
|
||||||
Text("API key (advanced)")
|
|
||||||
.font(.headline)
|
|
||||||
Text(
|
|
||||||
"You can also use an Anthropic API key, but this UI is instructions-only for now " +
|
|
||||||
"(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
.shadow(color: .clear, radius: 0)
|
|
||||||
.background(Color.clear)
|
|
||||||
|
|
||||||
if let status = self.anthropicAuthStatus {
|
|
||||||
Text(status)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task { await self.verifyAnthropicOAuthIfNeeded() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func permissionsPage() -> some View {
|
func permissionsPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Grant permissions")
|
Text("Grant permissions")
|
||||||
|
|||||||
@@ -37,18 +37,9 @@ extension OnboardingView {
|
|||||||
view.cliStatus = "Installed"
|
view.cliStatus = "Installed"
|
||||||
view.workspacePath = "/tmp/openclaw"
|
view.workspacePath = "/tmp/openclaw"
|
||||||
view.workspaceStatus = "Saved workspace"
|
view.workspaceStatus = "Saved workspace"
|
||||||
view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
|
|
||||||
view.anthropicAuthCode = "code#state"
|
|
||||||
view.anthropicAuthStatus = "Connected"
|
|
||||||
view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000)
|
|
||||||
view.anthropicAuthConnected = true
|
|
||||||
view.anthropicAuthAutoDetectClipboard = false
|
|
||||||
view.anthropicAuthAutoConnectClipboard = false
|
|
||||||
|
|
||||||
view.state.connectionMode = .local
|
view.state.connectionMode = .local
|
||||||
_ = view.welcomePage()
|
_ = view.welcomePage()
|
||||||
_ = view.connectionPage()
|
_ = view.connectionPage()
|
||||||
_ = view.anthropicAuthPage()
|
|
||||||
_ = view.wizardPage()
|
_ = view.wizardPage()
|
||||||
_ = view.permissionsPage()
|
_ = view.permissionsPage()
|
||||||
_ = view.cliPage()
|
_ = view.cliPage()
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import Testing
|
|
||||||
@testable import OpenClaw
|
|
||||||
|
|
||||||
@Suite(.serialized)
|
|
||||||
@MainActor
|
|
||||||
struct AnthropicAuthControlsSmokeTests {
|
|
||||||
@Test func anthropicAuthControlsBuildsBodyLocal() {
|
|
||||||
let pkce = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge")
|
|
||||||
let view = AnthropicAuthControls(
|
|
||||||
connectionMode: .local,
|
|
||||||
oauthStatus: .connected(expiresAtMs: 1_700_000_000_000),
|
|
||||||
pkce: pkce,
|
|
||||||
code: "code#state",
|
|
||||||
statusText: "Detected code",
|
|
||||||
autoDetectClipboard: false,
|
|
||||||
autoConnectClipboard: false)
|
|
||||||
_ = view.body
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func anthropicAuthControlsBuildsBodyRemote() {
|
|
||||||
let view = AnthropicAuthControls(
|
|
||||||
connectionMode: .remote,
|
|
||||||
oauthStatus: .missingFile,
|
|
||||||
pkce: nil,
|
|
||||||
code: "",
|
|
||||||
statusText: nil)
|
|
||||||
_ = view.body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import OpenClaw
|
|
||||||
|
|
||||||
@Suite
|
|
||||||
struct AnthropicAuthResolverTests {
|
|
||||||
@Test
|
|
||||||
func prefersOAuthFileOverEnv() throws {
|
|
||||||
let dir = FileManager().temporaryDirectory
|
|
||||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
let oauthFile = dir.appendingPathComponent("oauth.json")
|
|
||||||
let payload = [
|
|
||||||
"anthropic": [
|
|
||||||
"type": "oauth",
|
|
||||||
"refresh": "r1",
|
|
||||||
"access": "a1",
|
|
||||||
"expires": 1_234_567_890,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
|
|
||||||
try data.write(to: oauthFile, options: [.atomic])
|
|
||||||
|
|
||||||
let status = OpenClawOAuthStore.anthropicOAuthStatus(at: oauthFile)
|
|
||||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
|
||||||
"ANTHROPIC_API_KEY": "sk-ant-ignored",
|
|
||||||
], oauthStatus: status)
|
|
||||||
#expect(mode == .oauthFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func reportsOAuthEnvWhenPresent() {
|
|
||||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
|
||||||
"ANTHROPIC_OAUTH_TOKEN": "token",
|
|
||||||
], oauthStatus: .missingFile)
|
|
||||||
#expect(mode == .oauthEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func reportsAPIKeyEnvWhenPresent() {
|
|
||||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
|
||||||
"ANTHROPIC_API_KEY": "sk-ant-key",
|
|
||||||
], oauthStatus: .missingFile)
|
|
||||||
#expect(mode == .apiKeyEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func reportsMissingWhenNothingConfigured() {
|
|
||||||
let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile)
|
|
||||||
#expect(mode == .missing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import Testing
|
|
||||||
@testable import OpenClaw
|
|
||||||
|
|
||||||
@Suite
|
|
||||||
struct AnthropicOAuthCodeStateTests {
|
|
||||||
@Test
|
|
||||||
func parsesRawToken() {
|
|
||||||
let parsed = AnthropicOAuthCodeState.parse(from: "abcDEF1234#stateXYZ9876")
|
|
||||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func parsesBacktickedToken() {
|
|
||||||
let parsed = AnthropicOAuthCodeState.parse(from: "`abcDEF1234#stateXYZ9876`")
|
|
||||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func parsesCallbackURL() {
|
|
||||||
let raw = "https://console.anthropic.com/oauth/code/callback?code=abcDEF1234&state=stateXYZ9876"
|
|
||||||
let parsed = AnthropicOAuthCodeState.parse(from: raw)
|
|
||||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func extractsFromSurroundingText() {
|
|
||||||
let raw = "Paste the code#state value: abcDEF1234#stateXYZ9876 then return."
|
|
||||||
let parsed = AnthropicOAuthCodeState.parse(from: raw)
|
|
||||||
#expect(parsed == .init(code: "abcDEF1234", state: "stateXYZ9876"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import OpenClaw
|
|
||||||
|
|
||||||
@Suite
|
|
||||||
struct OpenClawOAuthStoreTests {
|
|
||||||
@Test
|
|
||||||
func returnsMissingWhenFileAbsent() {
|
|
||||||
let url = FileManager().temporaryDirectory
|
|
||||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)")
|
|
||||||
.appendingPathComponent("oauth.json")
|
|
||||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func usesEnvOverrideForOpenClawOAuthDir() throws {
|
|
||||||
let key = "OPENCLAW_OAUTH_DIR"
|
|
||||||
let previous = ProcessInfo.processInfo.environment[key]
|
|
||||||
defer {
|
|
||||||
if let previous {
|
|
||||||
setenv(key, previous, 1)
|
|
||||||
} else {
|
|
||||||
unsetenv(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dir = FileManager().temporaryDirectory
|
|
||||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
setenv(key, dir.path, 1)
|
|
||||||
|
|
||||||
#expect(OpenClawOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func acceptsPiFormatTokens() throws {
|
|
||||||
let url = try self.writeOAuthFile([
|
|
||||||
"anthropic": [
|
|
||||||
"type": "oauth",
|
|
||||||
"refresh": "r1",
|
|
||||||
"access": "a1",
|
|
||||||
"expires": 1_234_567_890,
|
|
||||||
],
|
|
||||||
])
|
|
||||||
|
|
||||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func acceptsTokenKeyVariants() throws {
|
|
||||||
let url = try self.writeOAuthFile([
|
|
||||||
"anthropic": [
|
|
||||||
"type": "oauth",
|
|
||||||
"refresh_token": "r1",
|
|
||||||
"access_token": "a1",
|
|
||||||
],
|
|
||||||
])
|
|
||||||
|
|
||||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func reportsMissingProviderEntry() throws {
|
|
||||||
let url = try self.writeOAuthFile([
|
|
||||||
"other": [
|
|
||||||
"type": "oauth",
|
|
||||||
"refresh": "r1",
|
|
||||||
"access": "a1",
|
|
||||||
],
|
|
||||||
])
|
|
||||||
|
|
||||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
func reportsMissingTokens() throws {
|
|
||||||
let url = try self.writeOAuthFile([
|
|
||||||
"anthropic": [
|
|
||||||
"type": "oauth",
|
|
||||||
"refresh": "",
|
|
||||||
"access": "a1",
|
|
||||||
],
|
|
||||||
])
|
|
||||||
|
|
||||||
#expect(OpenClawOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
|
|
||||||
let dir = FileManager().temporaryDirectory
|
|
||||||
.appendingPathComponent("openclaw-oauth-\(UUID().uuidString)", isDirectory: true)
|
|
||||||
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
|
|
||||||
let url = dir.appendingPathComponent("oauth.json")
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
|
|
||||||
try data.write(to: url, options: [.atomic])
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,9 +37,9 @@ For a general overview of onboarding paths, see [Onboarding Overview](/start/onb
|
|||||||
|
|
||||||
Where does the **Gateway** run?
|
Where does the **Gateway** run?
|
||||||
|
|
||||||
- **This Mac (Local only):** onboarding can run OAuth flows and write credentials
|
- **This Mac (Local only):** onboarding can configure auth and write credentials
|
||||||
locally.
|
locally.
|
||||||
- **Remote (over SSH/Tailnet):** onboarding does **not** run OAuth locally;
|
- **Remote (over SSH/Tailnet):** onboarding does **not** configure local auth;
|
||||||
credentials must exist on the gateway host.
|
credentials must exist on the gateway host.
|
||||||
- **Configure later:** skip setup and leave the app unconfigured.
|
- **Configure later:** skip setup and leave the app unconfigured.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user