Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
# cmux-owned Swift file length budget.
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
32547 CLI/cmux.swift
21983 Sources/TerminalController.swift
32553 CLI/cmux.swift
21996 Sources/TerminalController.swift
19875 Sources/Workspace.swift
19052 Sources/ContentView.swift
17902 Sources/AppDelegate.swift
17920 Sources/AppDelegate.swift
16522 Sources/GhosttyTerminalView.swift
13589 Sources/Panels/BrowserPanel.swift
11916 cmuxTests/AppDelegateShortcutRoutingTests.swift
Expand Down
6 changes: 6 additions & 0 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3377,6 +3377,12 @@ struct CMUXCLI {
print("Already signed in\(email.map { " as \($0)" } ?? ""). Use `cmux auth logout` to sign out first.")
break
}
if let signInURLResponse = try? client.sendV2(method: "auth.sign_in_url"),
let signInURL = signInURLResponse["url"] as? String,
!signInURL.isEmpty {
print("Fallback sign-in URL:")
print(signInURL)
}
print("Opening sign-in popup on the cmux web app.")
// auth.begin_sign_in blocks on the server side until the
// popup completes (or 5min timeout). The response is the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public final class ASWebBrowserAuthSessionFactory: HostBrowserAuthSessionFactory
callbackScheme: String,
completion: @escaping @MainActor (URL?) -> Void
) -> any HostBrowserAuthSession {
log.log("auth.webauth.makeSession signInURL=\(signInURL.absoluteString) callbackScheme=\(callbackScheme)")
let session = ASWebAuthenticationSession(
url: signInURL,
callbackURLScheme: callbackScheme,
Expand All @@ -46,7 +47,11 @@ public final class ASWebBrowserAuthSessionFactory: HostBrowserAuthSessionFactory
return { callbackURL, error in
Task { @MainActor in
if let error {
let nsError = error as NSError
log.log("auth.webauth.completion errorDomain=\(nsError.domain) errorCode=\(nsError.code) callback=\(callbackURL == nil ? "nil" : "present")")
log.log("auth.webauth failed: \(error)")
} else {
log.log("auth.webauth.completion error=nil callback=\(callbackURL == nil ? "nil" : "present")")
}
completion(callbackURL)
}
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ public struct AuthDebugLog: Sendable {
/// the `/tmp` debug file).
public func log(_ message: String) {
let redactedMessage = Self.redacted(message)
Self.logger.log(level: Self.logType(for: redactedMessage), "\(redactedMessage, privacy: .public)")
Self.logger.log(level: authDebugLogType(for: redactedMessage), "\(redactedMessage, privacy: .public)")
#if DEBUG && os(macOS)
let line = "[\(Date().formatted(Self.timestampFormat))] auth: \(redactedMessage)\n"
Self.appendToDebugFile(line)
for path in Self.debugLogPaths(environment: ProcessInfo.processInfo.environment) {
appendAuthDebugLineToFile(line, path: path)
}
#endif
}

Expand All @@ -35,49 +37,28 @@ public struct AuthDebugLog: Sendable {
/// Append one line with `O_APPEND` so concurrent logs from different actor
/// executors (the token stores, the browser flow) stay line-atomic instead
/// of interleaving through a shared seek+write.
private static func appendToDebugFile(_ line: String) {
let fd = open(debugLogPath, O_WRONLY | O_APPEND | O_CREAT, 0o600)
guard fd >= 0 else { return }
defer { close(fd) }
// Re-assert 0600 on pre-existing files (O_CREAT mode only applies at
// creation) so an old permissive log can't stay world-readable.
_ = fchmod(fd, 0o600)
let bytes = Array(line.utf8)
bytes.withUnsafeBufferPointer { buffer in
guard var baseAddress = buffer.baseAddress else { return }
var remaining = buffer.count
// Retry short writes/EINTR so a line is never half-appended.
while remaining > 0 {
let written = write(fd, baseAddress, remaining)
if written > 0 {
baseAddress += written
remaining -= written
continue
}
if written < 0 && errno == EINTR { continue }
break
}
static func debugLogPaths(environment: [String: String]) -> [String] {
var paths = [debugLogPath]
if let taggedPath = environment["CMUX_DEBUG_LOG"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!taggedPath.isEmpty,
taggedPath != debugLogPath {
paths.append(taggedPath)
}
return paths
}
#endif

private static func logType(for message: String) -> OSLogType {
let lowercased = message.lowercased()
if lowercased.contains("failed")
|| lowercased.contains("error")
|| lowercased.contains("invalid")
|| lowercased.contains("status=") {
return .error
}
return .debug
}
#endif

/// Redact token material, JWTs, and emails from a diagnostic message.
public static func redacted(_ message: String) -> String {
var redacted = message
let replacements: [(pattern: String, replacement: String)] = [
(#"(?i)\b(stack_access|stack_refresh|access_token|refresh_token|id_token|token|login_code|polling_code|code|state)=([^\s&#,)]+)"#, "$1=<redacted>"),
(#"(?i)\b(stack_access|stack_refresh|access_token|refresh_token|id_token|token|login_code|polling_code|code|state|cmux_auth_state)=([^\s&#,)]+)"#, "$1=<redacted>"),
(#"(?i)(stack_access|stack_refresh|access_token|refresh_token|id_token|token|login_code|polling_code|code|state|cmux_auth_state)%253[dD]([^\s&#,)]+)"#, "$1%253D<redacted>"),
(#"(?i)(stack_access|stack_refresh|access_token|refresh_token|id_token|token|login_code|polling_code|code|state|cmux_auth_state)%3[dD]([^\s&#,)]+)"#, "$1%3D<redacted>"),
(#"(?i)\b(access|refresh)=([^\s,;)]+)"#, "$1=<redacted>"),
(#"(?i)(access|refresh)%253[dD]([^\s,;)]+)"#, "$1%253D<redacted>"),
(#"(?i)(access|refresh)%3[dD]([^\s,;)]+)"#, "$1%3D<redacted>"),
(#"(?i)\b(authorization|x-stack-access-token|x-stack-refresh-token)\s*[:=]\s*(?:Bearer\s+)?([^\s,;)]+)"#, "$1=<redacted>"),
(#"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"#, "<email>"),
(#"[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}"#, "<jwt>"),
Expand All @@ -92,3 +73,41 @@ public struct AuthDebugLog: Sendable {
return redacted
}
}

private func authDebugLogType(for message: String) -> OSLogType {
let lowercased = message.lowercased()
if lowercased.contains("failed")
|| lowercased.contains("error")
|| lowercased.contains("invalid")
|| lowercased.contains("status=") {
return .error
}
return .debug
}

#if DEBUG && os(macOS)
private func appendAuthDebugLineToFile(_ line: String, path: String) {
let fd = open(path, O_WRONLY | O_APPEND | O_CREAT, 0o600)
guard fd >= 0 else { return }
defer { close(fd) }
// Re-assert 0600 on pre-existing files (O_CREAT mode only applies at
// creation) so an old permissive log can't stay world-readable.
_ = fchmod(fd, 0o600)
let bytes = Array(line.utf8)
bytes.withUnsafeBufferPointer { buffer in
guard var baseAddress = buffer.baseAddress else { return }
var remaining = buffer.count
// Retry short writes/EINTR so a line is never half-appended.
while remaining > 0 {
let written = write(fd, baseAddress, remaining)
if written > 0 {
baseAddress += written
remaining -= written
continue
}
if written < 0 && errno == EINTR { continue }
break
}
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation
import Testing
@testable import CmuxAuthRuntime

@Suite struct AuthDebugLogTests {
@Test func debugLogPathsIncludeTaggedDebugLogWhenConfigured() {
#if DEBUG && os(macOS)
let paths = AuthDebugLog.debugLogPaths(environment: [
"CMUX_DEBUG_LOG": "/tmp/cmux-debug-safari.log",
])

#expect(paths == ["/tmp/cmux-auth-debug.log", "/tmp/cmux-debug-safari.log"])
#endif
}

@Test func redactionCoversCallbackTokenQueryValues() {
let redacted = AuthDebugLog.redacted(
"auth.callback.complete url=cmux-dev://auth-callback?stack_refresh=refresh-secret&stack_access=access-secret&cmux_auth_state=state-secret"
)

#expect(redacted.contains("refresh-secret") == false)
#expect(redacted.contains("access-secret") == false)
#expect(redacted.contains("state-secret") == false)
#expect(redacted.contains("stack_refresh=<redacted>"))
#expect(redacted.contains("stack_access=<redacted>"))
#expect(redacted.contains("cmux_auth_state=<redacted>"))
}

@Test func redactionCoversEncodedNestedCallbackState() {
let redacted = AuthDebugLog.redacted(
"auth.browser.session.create signInURL=http://localhost:4577/handler/native-sign-in?after_auth_return_to=http%3A%2F%2Flocalhost%3A4577%2Fhandler%2Fafter-sign-in%3Fnative_app_return_to%3Dcmux-dev-safauth%253A%252F%252Fauth-callback%253Fcmux_auth_state%253Dstate-secret"
)

#expect(redacted.contains("state-secret") == false)
#expect(redacted.contains("cmux_auth_state%253D<redacted>"))
}
}
Loading
Loading