diff --git a/Package.swift b/Package.swift index c1eaa94..2e72726 100644 --- a/Package.swift +++ b/Package.swift @@ -11,8 +11,13 @@ let package = Package( .executable(name: "T3Notch", targets: ["T3Notch"]), ], targets: [ + .systemLibrary( + name: "CCommonCrypto", + path: "Sources/CCommonCrypto" + ), .target( name: "T3NotchCore", + dependencies: ["CCommonCrypto"], path: "Sources/T3NotchCore" ), .executableTarget( diff --git a/README.md b/README.md index 43e8a0e..d9b5495 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ needs an answer. and the files it changed, whatever is still in flight tinted. - **The task list** from the agent's plan, with a tick animation as steps land. - **Approvals and questions answered in place**, one slide at a time. -- **One card per agent** when several are running, grouped by project. +- **One card per agent** when several are running, grouped by machine and project. +- **Local and remote machines together**, with direct LAN/Tailscale/HTTPS pairing + and an optional T3 Connect compatibility import. - **Finished agents stay pinned** until you have actually looked at them. - **Milestones get a moment**: a banner when a plan completes, confetti when a branch lands. @@ -114,6 +116,105 @@ paste a bearer token into the panel. Tokens live in the Keychain under is not the build reading it — otherwise every update would open the Keychain's password prompt, which costs more than minting a fresh token does. +That signature-change behavior applies only to the auto-remintable local token. +Remote credentials use a separate, versioned Keychain vault and survive app +updates. T3Notch asks you to unlock that vault if macOS requires access again; it +never deletes a remote session merely because the app signature changed. + +## Remote machines + +T3Notch can monitor the T3 Code environments on a Mac mini, another MacBook, or +any compatible remote server. This is agent monitoring and response—not screen +sharing, a remote terminal, or general desktop control. The notch can: + +- show local and remote agents at the same time; +- answer approvals and user questions on the machine that originated them; +- interrupt the current turn; +- deep-link to the corresponding remote T3 Code thread. + +It does not start agents, manage projects, inspect remote worktrees, or expose +remote files. Merge watching remains local to this Mac. + +### Direct pairing + +On the machine running T3 Code, open **Settings → Connections**, enable network +access, and create a pairing link. For a headless Mac mini, `npx t3 serve` prints +the same link. On the Mac running T3Notch: + +1. Open **T3Notch → Settings → Machines**. +2. Choose **Add…** and paste the complete pairing link. +3. Alternatively, use the advanced backend URL and pairing-code fields. + +HTTPS or a private Tailnet is recommended. Plain HTTP is accepted for loopback; +a non-loopback HTTP endpoint requires a separate, persisted acknowledgement. +T3Notch reads the environment descriptor before consuming the one-time code, +exchanges it for a DPoP-bound session with only +`orchestration:read orchestration:operate`, verifies that session, and immediately +forgets the pairing code. Tokens and URL fragments are never included in an +opened thread URL. + +See T3 Code's [Remote Access guide](https://github.com/pingdotgg/t3code/blob/main/docs/user/remote-access.md) +for LAN, Tailscale, and HTTPS server setup. + +### T3 Connect compatibility import + +Release builds embed the same public production Clerk and relay configuration as +T3 Code. **Import T3 Connect…** is enabled when T3 Code has a safe, recognized +signed-in session at `~/.t3/userdata/clerk-tokens.json`. Detection checks +ownership, permissions, file type, and schema without decrypting anything or +touching Keychain. An unsafe file produces an explanatory Machines row with a +copyable permission-fix command; T3Notch does not modify T3 Code’s file itself. +Before copying that command or importing a session, T3Notch requires confirmation +that the Mac is private and trusted. Do not use this compatibility import on a +public, shared, borrowed, or otherwise untrusted Mac: `chmod 600` limits access +to the current macOS account, but it cannot make a shared account safe. + +Import is explicit. macOS may ask once for access to T3 Code's Electron Safe +Storage key; T3Notch copies the active Clerk client credential into its own +Keychain and never modifies T3 Code's file, Keychain item, account, or linked +environments. Production/nightly T3 Code storage is supported; dev-channel +storage is intentionally not. + +T3 Connect is a compatibility adapter over the current upstream Clerk, relay, and +Electron contracts. If T3 Code rotates its encrypted session, Settings reports +that a new session is available and re-import remains a click. If T3 Code logs out +or Clerk rejects the copy, T3Notch purges only its imported copy and asks you to +import again. Direct and local monitoring continue independently. + +Source builds use the production public values by default and may override all +three together for another deployment: + +```bash +export T3CODE_CLERK_PUBLISHABLE_KEY=... +export T3CODE_CLERK_JWT_TEMPLATE=t3-relay +export T3CODE_RELAY_URL=https://relay.example.com +./Scripts/bundle.sh release +``` + +No Clerk secret key is accepted or embedded. See T3 Code's +[Connect configuration guide](https://github.com/pingdotgg/t3code/blob/main/docs/cloud/t3-connect-clerk.md). + +### Connection behavior + +Every enabled machine stays connected concurrently. Local discovery is +independent and remains usable through remote failures. Active shells poll at +800 ms; idle local and remote shells back off to 3 and 5 seconds respectively. +Failures use jittered exponential backoff from 500 ms to 30 seconds, with +immediate retry on wake, network restoration, and manual reconnect. + +When direct and T3 Connect access report the same stable environment ID, they +appear as one logical machine. T3Notch prefers loopback, then a saved direct +endpoint, falls back to Connect after repeated direct failure or credential +expiry, probes direct every 60 seconds, and switches back after two successful +probes. + +Remote profiles in `UserDefaults` contain only labels, endpoint URLs, enabled +state, and the insecure-HTTP acknowledgement. Access tokens, imported Clerk +credentials, relay tokens, and the app-scoped P-256 key live in the +`gg.t3tools.t3notch` Keychain vault. Diagnostics deliberately exclude +authorization headers, DPoP proofs, pairing credentials, Clerk tokens, OAuth +credential bodies, and private keys. + ## Settings **Settings…** in the menu bar item (⌘,) opens the control panel: dark rounded cards @@ -179,11 +280,15 @@ Updates ## How it works -- **T3NotchCore** — models, HTTP client, polling transport, derivations. No UI, and - the only part under test. +- **T3NotchCore** — models, DPoP authorization, pairing and Connect clients, + per-machine polling, multi-environment coordination, and derivations. No UI, + and the only part under test. - **T3Notch** — the AppKit panel and the SwiftUI views. -v1 polls `/api/orchestration/shell` and `/api/orchestration/threads/:id` adaptively. +A session per enabled environment polls `/api/orchestration/shell` and +`/api/orchestration/threads/:id` adaptively. Composite environment/thread IDs keep +identical server-local IDs from colliding, and dispatch is always routed back to +the originating environment. A WebSocket Effect-RPC transport can replace `PollingTransport` later behind the same `T3Transport` protocol. diff --git a/Scripts/bundle.sh b/Scripts/bundle.sh index 886610c..0a2aede 100755 --- a/Scripts/bundle.sh +++ b/Scripts/bundle.sh @@ -84,6 +84,26 @@ PLIST -c "Set :CFBundleVersion $BUILD_NUMBER" "$APP_DIR/Contents/Info.plist" >/dev/null echo "==> Version ${VERSION} (${BUILD_NUMBER})" +# T3 Connect consumes only public client configuration. These production +# defaults match T3 Code's packaged app; source-build environments may override +# all three for a different public Clerk/relay deployment. +CONNECT_CLERK_PUBLISHABLE_KEY="${T3CODE_CLERK_PUBLISHABLE_KEY:-pk_live_Y2xlcmsudDMuY29kZXMk}" +CONNECT_JWT_TEMPLATE="${T3CODE_CLERK_JWT_TEMPLATE:-t3-relay}" +CONNECT_RELAY_URL="${T3CODE_RELAY_URL:-https://relay.t3.codes}" + +if [[ -n "$CONNECT_CLERK_PUBLISHABLE_KEY" \ + && -n "$CONNECT_JWT_TEMPLATE" \ + && -n "$CONNECT_RELAY_URL" ]]; then + /usr/libexec/PlistBuddy \ + -c "Add :T3ConnectClerkPublishableKey string $CONNECT_CLERK_PUBLISHABLE_KEY" \ + -c "Add :T3ConnectJWTTemplate string $CONNECT_JWT_TEMPLATE" \ + -c "Add :T3ConnectRelayURL string $CONNECT_RELAY_URL" \ + "$APP_DIR/Contents/Info.plist" >/dev/null + echo "==> Embedded public T3 Connect configuration" +else + echo "==> T3 Connect disabled (public configuration incomplete)" +fi + if [[ "$SIGNING_IDENTITY" == "-" ]]; then echo "==> Ad-hoc codesign" codesign --force --deep --sign - "$APP_DIR" diff --git a/Sources/CCommonCrypto/module.modulemap b/Sources/CCommonCrypto/module.modulemap new file mode 100644 index 0000000..767f78b --- /dev/null +++ b/Sources/CCommonCrypto/module.modulemap @@ -0,0 +1,4 @@ +module CCommonCrypto [system] { + header "shim.h" + export * +} diff --git a/Sources/CCommonCrypto/shim.h b/Sources/CCommonCrypto/shim.h new file mode 100644 index 0000000..c332624 --- /dev/null +++ b/Sources/CCommonCrypto/shim.h @@ -0,0 +1 @@ +#include diff --git a/Sources/T3Notch/AgentStore.swift b/Sources/T3Notch/AgentStore.swift index 614245b..5fed2cb 100644 --- a/Sources/T3Notch/AgentStore.swift +++ b/Sources/T3Notch/AgentStore.swift @@ -13,6 +13,16 @@ final class AgentStore { case attention } + private enum RemoteRestoreResult: Sendable { + case register( + EnvironmentProfile, + EnvironmentDescriptor, + ServerEndpoint, + DPoPHTTPAuthorizer + ) + case placeholder(EnvironmentProfile, EnvironmentConnectionState) + } + var connectionState: ConnectionState = .connecting var projects: [ProjectShell] = [] var threads: [ThreadShell] = [] @@ -47,12 +57,36 @@ final class AgentStore { var onboardingMessage: String? var answeredRequestIds: Set = [] var environment: EnvironmentDescriptor? + var machines: [EnvironmentSnapshot] = [] + var t3ConnectDetection: T3ConnectSessionDetection = .unavailable + var t3ConnectEnvironments: [T3ConnectEnvironment] = [] + var t3ConnectSessionUpdateAvailable = false + var remoteOperationMessage: String? + var isRemoteOperationRunning = false + var remoteVaultLocked = false - private var transport: (any T3Transport)? private(set) var endpoint = ServerEndpoint() - private var shellTask: Task? - private var detailTask: Task? - private var subscribedThreadId: String? + private let coordinator = MultiEnvironmentCoordinator() + private let profileStore = EnvironmentProfileStore() + private let remoteVault = RemoteCredentialVault() + private let t3ConnectImporter = ElectronSafeStorageImporter() + private var coordinatorTask: Task? + private var snapshotsByEnvironment: [EnvironmentID: EnvironmentSnapshot] = [:] + /// Relay shells may stop returning a thread as soon as its turn finishes. + /// Keep that last transition locally so remote results still get one review. + private var retainedRemoteCompletions: [EnvironmentID: [String: ThreadShell]] = [:] + private var knownProjectsByEnvironment: [EnvironmentID: [String: ProjectShell]] = [:] + private var scopedThreads: [String: ScopedThreadID] = [:] + private var scopedProjects: [String: ScopedProjectID] = [:] + private var localEnvironmentID: EnvironmentID? + private var dpopSigner: DPoPSigner? + private var t3ConnectClient: T3ConnectClient? + private var t3ConnectConfiguration: T3ConnectConfiguration? + private var t3ConnectEnabledStates: [EnvironmentID: Bool] = [:] + private var remoteRestoreInFlight = false + private var directFailureCounts: [EnvironmentID: Int] = [:] + private var directProbeSuccesses: [EnvironmentID: Int] = [:] + private var connectFallbacksInFlight: Set = [] private var clockTimer: Timer? private var celebrationTask: Task? private var celebrationToken = 0 @@ -67,13 +101,16 @@ final class AgentStore { /// Which forge setting `mergeWatcher` was built for, so it is only rebuilt /// when that actually changes — rebuilding resets what it has seen. private var watcherUsesForge: Bool - private var hasReviewBaseline = false + private var reviewBaselines: Set = [] + private var reviewRevision = 0 init(settings: SettingsStore) { let usesForge = settings.values.askForgeForMerges self.settings = settings watcherUsesForge = usesForge mergeWatcher = MergeWatcher(forge: usesForge ? .gh : .disabled) + observeCoordinator() + Task { await refreshT3ConnectDetectionNow() } } var focusedThread: ThreadShell? { @@ -81,8 +118,24 @@ final class AgentStore { return threads.first(where: { $0.id == focusedThreadId }) ?? activeThreads.first } + var focusedScopedThread: ScopedThreadID? { + focusedThreadId.flatMap { scopedThreads[$0] } + } + + func environmentID(for thread: ThreadShell) -> EnvironmentID? { + scopedThreads[thread.id]?.environmentID + } + + func machineLabel(for thread: ThreadShell) -> String { + guard let id = environmentID(for: thread) else { return "This Mac" } + return snapshotsByEnvironment[id]?.descriptor?.label + ?? snapshotsByEnvironment[id]?.profile.label + ?? id.rawValue + } + var activeThreads: [ThreadShell] { - threads.compactMap { thread -> (ThreadShell, AgentAwarenessPhase)? in + _ = reviewRevision + return threads.compactMap { thread -> (ThreadShell, AgentAwarenessPhase)? in guard let phase = resolveThreadAwarenessPhase(thread) else { return nil } switch phase { case .running, .starting, .waitingForApproval, .waitingForInput, .failed: @@ -104,6 +157,7 @@ final class AgentStore { /// Completed threads the user has not dealt with yet. `settledAt` is T3 Code's /// own "handled" marker, so settling there clears the notch too. func awaitsReview(_ thread: ThreadShell) -> Bool { + _ = reviewRevision guard settings.values.keepFinishedUntilReviewed else { return false } guard resolveThreadAwarenessPhase(thread) == .completed else { return false } guard thread.settledAt == nil else { return false } @@ -128,6 +182,10 @@ final class AgentStore { // Acting on the thread means the banner has served its purpose. dismissCelebration() reviewStore.insert(completionKey(for: thread)) + if let scoped = scopedThreads[thread.id] { + retainedRemoteCompletions[scoped.environmentID]?[scoped.threadID] = nil + } + reviewRevision &+= 1 if focusedThreadId == thread.id { replaceFocusedThread( with: activeThreads.first { $0.id != thread.id }?.id @@ -289,14 +347,26 @@ final class AgentStore { /// is no deep link to a single thread. Bringing it forward beats opening a /// duplicate of the same thread in a browser tab. func openInT3Code(_ thread: ThreadShell) { - if settings.values.openInDesktopApp, T3CodeApp.activate() { + guard let scoped = scopedThreads[thread.id], + let snapshot = snapshotsByEnvironment[scoped.environmentID] + else { markReviewed(thread) return } - if let environmentId = environment?.environmentId, + if snapshot.activeAccessPath == .local, + settings.values.openInDesktopApp, + T3CodeApp.activate() + { + markReviewed(thread) + return + } + let baseURL = snapshot.activeAccessPath == .t3Connect + ? URL(string: "https://app.t3.codes/")! + : snapshot.profile.directEndpoint?.baseURL ?? endpoint.baseURL + if let environmentId = snapshot.descriptor?.environmentId, let url = URL( - string: "/threads/\(environmentId)/\(thread.id)", - relativeTo: endpoint.baseURL + string: "/threads/\(environmentId)/\(scoped.threadID)", + relativeTo: baseURL ) { NSWorkspace.shared.open(url) } @@ -334,6 +404,49 @@ final class AgentStore { } } + struct MachineThreadGroup: Identifiable { + let environmentID: EnvironmentID + let label: String + let source: EnvironmentSource + let projects: [(project: ProjectShell, threads: [ThreadShell])] + var id: EnvironmentID { environmentID } + } + + var activeThreadsByMachine: [MachineThreadGroup] { + var machineOrder: [EnvironmentID] = [] + var byMachine: [EnvironmentID: [ThreadShell]] = [:] + for thread in activeThreads { + guard let environmentID = environmentID(for: thread) else { continue } + if byMachine[environmentID] == nil { machineOrder.append(environmentID) } + byMachine[environmentID, default: []].append(thread) + } + return machineOrder.compactMap { environmentID in + guard let machineThreads = byMachine[environmentID] else { return nil } + var projectOrder: [String] = [] + var byProject: [String: [ThreadShell]] = [:] + for thread in machineThreads { + if byProject[thread.projectId] == nil { projectOrder.append(thread.projectId) } + byProject[thread.projectId, default: []].append(thread) + } + let groups = projectOrder.compactMap { projectID + -> (project: ProjectShell, threads: [ThreadShell])? in + guard let threads = byProject[projectID] else { return nil } + let project = projects.first { $0.id == projectID } + ?? ProjectShell(id: projectID, title: "Project") + return (project, threads) + } + let label = snapshotsByEnvironment[environmentID]?.descriptor?.label + ?? snapshotsByEnvironment[environmentID]?.profile.label + ?? environmentID.rawValue + return MachineThreadGroup( + environmentID: environmentID, + label: label, + source: snapshotsByEnvironment[environmentID]?.activeAccessPath ?? .local, + projects: groups + ) + } + } + var elapsedLabel: String? { focusedThread.flatMap { elapsedLabel(for: $0) } } @@ -376,7 +489,11 @@ final class AgentStore { /// Machine the work is happening on, as reported by the server environment. var machineLabel: String? { - environment?.label?.nilIfBlank + guard let id = focusedScopedThread?.environmentID else { + return environment?.label?.nilIfBlank + } + return snapshotsByEnvironment[id]?.descriptor?.label?.nilIfBlank + ?? snapshotsByEnvironment[id]?.profile.label.nilIfBlank } var platformLabel: String? { @@ -433,8 +550,8 @@ final class AgentStore { } func start() async { - let endpoint = await ServerDiscovery.resolveEndpoint() - self.endpoint = endpoint + let localEndpoint = await ServerDiscovery.resolveEndpoint() + self.endpoint = localEndpoint var token = KeychainStore.loadToken() if token == nil { @@ -454,7 +571,7 @@ final class AgentStore { guard let token else { return } do { - _ = try await TokenMinting.verifyToken(token: token, endpoint: endpoint) + _ = try await TokenMinting.verifyToken(token: token, endpoint: localEndpoint) } catch { needsOnboarding = true onboardingMessage = @@ -464,30 +581,183 @@ final class AgentStore { } needsOnboarding = false - let client = T3HTTPClient(endpoint: endpoint, token: token) - environment = try? await client.fetchEnvironment() - let polling = PollingTransport(client: client) - polling.onConnectionStateChange = { [weak self] state in - Task { @MainActor in - self?.connectionState = state - if state == .unauthorized { - self?.needsOnboarding = true - self?.onboardingMessage = "Session expired. Paste a new bearer token." + let client = T3HTTPClient(endpoint: localEndpoint, token: token) + let localDescriptor = try? await client.fetchEnvironment() + environment = localDescriptor + let localID = EnvironmentID(localDescriptor?.environmentId?.nilIfBlank ?? "local") + localEnvironmentID = localID + normalizeT3ConnectProfiles(localEnvironmentID: localID) + coordinator.register( + profile: EnvironmentProfile( + environmentID: localID, + label: localDescriptor?.label?.nilIfBlank ?? "This Mac", + directEndpoint: localEndpoint, + source: .local + ), + descriptor: localDescriptor, + endpoint: localEndpoint, + authorizer: BearerHTTPAuthorizer(token: token) + ) + connectionState = .connecting + startMergeWatch() + await refreshT3ConnectDetectionNow() + await restoreRemoteMachines() + } + + private func restoreRemoteMachines() async { + guard !remoteRestoreInFlight else { return } + remoteRestoreInFlight = true + defer { remoteRestoreInFlight = false } + + let profiles = profileStore.load() + t3ConnectEnabledStates = Dictionary( + profiles.map { ($0.environmentID, $0.enabled) }, + uniquingKeysWith: { _, latest in latest } + ) + for profile in profiles where !profile.enabled { + installPlaceholder(profile: profile, state: .offline("Disabled")) + } + let document: RemoteCredentialDocument + do { + document = try remoteVault.loadWithoutPrompt() + remoteVaultLocked = false + } catch RemoteCredentialVaultError.locked { + remoteVaultLocked = true + for profile in profiles where profile.enabled { + installPlaceholder(profile: profile, state: .credentialLocked) + } + return + } catch { + remoteOperationMessage = error.localizedDescription + return + } + do { + let signer = try DPoPSigner(privateKeyRawRepresentation: document.dpopPrivateKey) + dpopSigner = signer + if document.dpopPrivateKey == nil { + let raw = await signer.privateKeyRawRepresentation + try remoteVault.update { $0.dpopPrivateKey = raw } + } + } catch { + remoteOperationMessage = error.localizedDescription + return + } + guard let signer = dpopSigner else { return } + let configurationAvailable = t3ConnectConfiguration != nil + let results = await withTaskGroup( + of: RemoteRestoreResult?.self, + returning: [RemoteRestoreResult].self + ) { group in + for profile in profiles where profile.enabled { + group.addTask { + if profile.source == .t3Connect { + guard configurationAvailable else { + return .placeholder( + profile, + .incompatible("T3 Connect is not configured in this build.") + ) + } + guard document.importedT3Connect != nil else { + return .placeholder(profile, .unauthorized) + } + guard let endpoint = profile.directEndpoint, + let credential = document.connectEnvironmentCredentials[ + profile.environmentID.rawValue + ], + !credential.needsRefresh + else { + return .placeholder(profile, .connecting) + } + let authorizer = DPoPHTTPAuthorizer( + accessToken: credential.accessToken, + signer: signer + ) + let client = T3HTTPClient(endpoint: endpoint, authorizer: authorizer) + guard let descriptor = try? await client.fetchEnvironment(), + descriptor.environmentId == profile.environmentID.rawValue, + (try? await client.verifySession()) != nil + else { + return .placeholder(profile, .connecting) + } + return .register(profile, descriptor, endpoint, authorizer) + } + + guard profile.source == .direct else { return nil } + guard let endpoint = profile.directEndpoint, + let credential = document.environmentCredentials[ + profile.environmentID.rawValue + ] + else { + return .placeholder(profile, .needsPairing) + } + guard !credential.needsRefresh else { + return .placeholder(profile, .needsPairing) + } + let authorizer = DPoPHTTPAuthorizer( + accessToken: credential.accessToken, + signer: signer + ) + let descriptor = try? await T3HTTPClient( + endpoint: endpoint, + authorizer: authorizer + ).fetchEnvironment() + guard let descriptor, + descriptor.environmentId == profile.environmentID.rawValue + else { + return .placeholder( + profile, + .incompatible("The endpoint reports a different environment.") + ) + } + return .register(profile, descriptor, endpoint, authorizer) } } + + var resolved: [RemoteRestoreResult] = [] + for await result in group { + if let result { resolved.append(result) } + } + return resolved } - transport = polling - connectionState = .connecting - shellTask?.cancel() - shellTask = Task { [weak self] in - guard let self else { return } - for await snapshot in polling.shell { - await self.applyShell(snapshot) + for result in results { + switch result { + case let .register(profile, descriptor, endpoint, authorizer): + coordinator.register( + profile: profile, + descriptor: descriptor, + endpoint: endpoint, + authorizer: authorizer + ) + case let .placeholder(profile, state): + installPlaceholder(profile: profile, state: state) } } + configureT3Connect() + for profile in profiles where profile.source == .direct && profile.enabled { + guard snapshotsByEnvironment[profile.environmentID]?.connectionState + == .needsPairing + else { + continue + } + Task { await attemptConnectFallback(profile.environmentID) } + } + } - startMergeWatch() + private func installPlaceholder( + profile: EnvironmentProfile, + state: EnvironmentConnectionState + ) { + coordinator.suspend(profile.environmentID) + let snapshot = EnvironmentSnapshot( + profile: profile, + descriptor: nil, + connectionState: state, + activeAccessPath: profile.source, + shell: nil + ) + snapshotsByEnvironment[profile.environmentID] = snapshot + rebuildFlattenedWorld() } /// Settings the views need. Reading them through the store keeps the panel's @@ -540,6 +810,667 @@ final class AgentStore { } } + var remoteMachines: [EnvironmentSnapshot] { + machines.filter { $0.profile.source != .local } + } + + var canImportT3Connect: Bool { + t3ConnectConfiguration != nil + && { + if case .signedIn = t3ConnectDetection { return true } + return false + }() + } + + var canRepairT3ConnectPermissions: Bool { + if case .unsafePermissions = t3ConnectDetection { return true } + return false + } + + var t3ConnectImportHasProblem: Bool { + switch t3ConnectDetection { + case .unsafePermissions, .incompatible: + true + default: + false + } + } + + var hasImportedT3Connect: Bool { t3ConnectClient != nil } + + var showsT3Connect: Bool { + guard t3ConnectConfiguration != nil else { return hasImportedT3Connect } + return switch t3ConnectDetection { + case .signedIn, .unsafePermissions, .incompatible: + true + case .unavailable, .signedOut: + hasImportedT3Connect + } + } + + var t3ConnectImportDetail: String { + switch t3ConnectDetection { + case .unsafePermissions: + "T3 Code’s session file is writable by other local users. " + + "Run chmod 600 ~/.t3/userdata/clerk-tokens.json, then refresh." + case let .incompatible(reason): + reason + case .signedIn: + "Use the account already signed in to T3 Code. Importing asks for Keychain access once." + case .signedOut: + "Sign in to T3 Code before importing its T3 Connect session." + case .unavailable: + "No compatible T3 Code session was found." + } + } + + func copyT3ConnectPermissionFix() { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString( + "chmod 600 ~/.t3/userdata/clerk-tokens.json", + forType: .string + ) + } + + func refreshT3ConnectDetection() { + Task { await refreshT3ConnectDetectionNow() } + } + + private func refreshT3ConnectDetectionNow() async { + let importer = t3ConnectImporter + let vault = remoteVault + let result = await Task.detached(priority: .utility) { + let configuration = T3ConnectConfiguration.load() + guard configuration != nil else { + return ( + configuration, + T3ConnectSessionDetection.unavailable, + Optional.none + ) + } + let detection = importer.detect() + let imported = try? vault.document().importedT3Connect + return (configuration, detection, imported) + }.value + + t3ConnectConfiguration = result.0 + guard result.0 != nil else { + t3ConnectDetection = .unavailable + return + } + t3ConnectDetection = result.1 + switch t3ConnectDetection { + case let .signedIn(ciphertextFingerprint): + if let imported = result.2 { + t3ConnectSessionUpdateAvailable = + imported.ciphertextFingerprint != ciphertextFingerprint + } else { + t3ConnectSessionUpdateAvailable = false + } + case .signedOut, .unavailable: + t3ConnectSessionUpdateAvailable = false + if result.2 != nil { + purgeImportedT3ConnectAfterLogout() + } + case .unsafePermissions, .incompatible: + t3ConnectSessionUpdateAvailable = false + } + } + + func pairRemoteMachine( + pairingURL: String?, + host: String?, + code: String?, + allowsInsecureHTTP: Bool + ) async throws { + isRemoteOperationRunning = true + remoteOperationMessage = nil + defer { isRemoteOperationRunning = false } + let target: RemotePairingTarget + if let pairingURL = pairingURL?.nilIfBlank { + target = try RemotePairingTarget(pairingURL: pairingURL) + } else { + target = try RemotePairingTarget( + host: host ?? "", + pairingCode: code ?? "" + ) + } + let signer = try await ensureDPoPSigner(allowsPrompt: false) + let result = try await RemotePairingClient(signer: signer).pair( + target: target, + allowsInsecureHTTP: allowsInsecureHTTP + ) + try profileStore.upsert(result.profile) + t3ConnectEnabledStates[result.profile.environmentID] = result.profile.enabled + try remoteVault.update { + $0.environmentCredentials[result.profile.environmentID.rawValue] = result.credential + } + snapshotsByEnvironment.removeValue(forKey: result.profile.environmentID) + coordinator.register( + profile: result.profile, + descriptor: result.descriptor, + endpoint: target.endpoint, + authorizer: DPoPHTTPAuthorizer( + accessToken: result.credential.accessToken, + signer: signer + ) + ) + } + + func setMachineEnabled(_ environmentID: EnvironmentID, enabled: Bool) { + guard var profile = profileStore.load() + .first(where: { $0.environmentID == environmentID }) + else { + return + } + profile.enabled = enabled + do { + try profileStore.upsert(profile) + t3ConnectEnabledStates[environmentID] = enabled + if enabled { + Task { await restoreRemoteMachines() } + } else { + retainedRemoteCompletions.removeValue(forKey: environmentID) + knownProjectsByEnvironment.removeValue(forKey: environmentID) + installPlaceholder(profile: profile, state: .offline("Disabled")) + } + } catch { + remoteOperationMessage = error.localizedDescription + } + } + + func reconnectMachine(_ environmentID: EnvironmentID) { + coordinator.reconnect(environmentID) + } + + func removeMachine(_ environmentID: EnvironmentID) { + do { + let removedProfile = profileStore.load().first { + $0.environmentID == environmentID + } + if var removedProfile, removedProfile.source == .t3Connect { + removedProfile.enabled = false + try profileStore.upsert(removedProfile) + t3ConnectEnabledStates[environmentID] = false + try remoteVault.removeEnvironment(environmentID) + retainedRemoteCompletions.removeValue(forKey: environmentID) + knownProjectsByEnvironment.removeValue(forKey: environmentID) + installPlaceholder( + profile: removedProfile, + state: .offline("Disabled") + ) + return + } + try profileStore.remove(environmentID) + t3ConnectEnabledStates.removeValue(forKey: environmentID) + try remoteVault.removeEnvironment(environmentID) + coordinator.remove(environmentID) + snapshotsByEnvironment.removeValue(forKey: environmentID) + rebuildFlattenedWorld() + } catch { + remoteOperationMessage = error.localizedDescription + } + } + + func unlockRemoteCredentials() async { + guard !isRemoteOperationRunning else { return } + isRemoteOperationRunning = true + defer { isRemoteOperationRunning = false } + do { + _ = try remoteVault.unlock() + remoteVaultLocked = false + await restoreRemoteMachines() + } catch { + remoteOperationMessage = error.localizedDescription + } + } + + func importT3Connect() async { + isRemoteOperationRunning = true + remoteOperationMessage = nil + defer { isRemoteOperationRunning = false } + do { + let imported = try t3ConnectImporter.importSession() + let signer = try await ensureDPoPSigner(allowsPrompt: true) + guard let configuration = t3ConnectConfiguration else { + throw T3ConnectError.invalidConfiguration + } + let client = T3ConnectClient( + configuration: configuration, + vault: remoteVault, + signer: signer + ) + try await client.importSession(imported) + t3ConnectClient = client + try await refreshT3ConnectEnvironments(connectEnabled: true) + t3ConnectSessionUpdateAvailable = false + } catch T3ConnectError.unauthorized { + purgeImportedT3ConnectAfterLogout() + remoteOperationMessage = T3ConnectError.unauthorized.localizedDescription + } catch { + remoteOperationMessage = error.localizedDescription + } + } + + func refreshT3ConnectEnvironments(connectEnabled: Bool = false) async throws { + guard let client = t3ConnectClient else { + throw T3ConnectError.notImported + } + let inventory = try await client.listEnvironments() + let legacyExclusionKey = + "gg.t3tools.t3notch.excludedT3ConnectEnvironments.v1" + let legacyExcludedIDs = Set( + UserDefaults.standard.stringArray(forKey: legacyExclusionKey) ?? [] + ) + for environment in inventory + where legacyExcludedIDs.contains(environment.environmentID.rawValue) { + if !profileStore.load().contains(where: { + $0.environmentID == environment.environmentID + }) { + try profileStore.upsert( + EnvironmentProfile( + environmentID: environment.environmentID, + label: environment.label, + directEndpoint: environment.endpoint, + source: .t3Connect, + enabled: false + ) + ) + t3ConnectEnabledStates[environment.environmentID] = false + } + } + UserDefaults.standard.removeObject(forKey: legacyExclusionKey) + let environments = inventory.filter { environment in + environment.environmentID != localEnvironmentID + } + + // Inventory discovery is read-only. A newly imported T3 Connect session + // must never begin monitoring every linked machine automatically; save + // each new remote environment as disabled until its toggle is enabled. + var profiles = profileStore.load() + for environment in environments where !profiles.contains(where: { + $0.environmentID == environment.environmentID + }) { + let profile = EnvironmentProfile( + environmentID: environment.environmentID, + label: environment.label, + directEndpoint: environment.endpoint, + source: .t3Connect, + enabled: false + ) + try profileStore.upsert(profile) + profiles.append(profile) + t3ConnectEnabledStates[environment.environmentID] = false + } + + t3ConnectEnvironments = environments + if connectEnabled { + for environment in environments { + let existing = profiles.first { + $0.environmentID == environment.environmentID + } + if existing?.source == .direct || existing?.enabled == false { + continue + } + if let snapshot = snapshotsByEnvironment[environment.environmentID], + snapshot.activeAccessPath == .t3Connect, + snapshot.connectionState == .connected + { + continue + } + do { + try await connectT3ConnectEnvironment(environment) + t3ConnectEnabledStates[environment.environmentID] = true + } catch T3ConnectError.unauthorized { + throw T3ConnectError.unauthorized + } catch { + remoteOperationMessage = error.localizedDescription + } + } + } + } + + func refreshT3Connect() async { + isRemoteOperationRunning = true + remoteOperationMessage = nil + defer { isRemoteOperationRunning = false } + do { + try await refreshT3ConnectEnvironments(connectEnabled: true) + } catch T3ConnectError.unauthorized { + purgeImportedT3ConnectAfterLogout() + remoteOperationMessage = T3ConnectError.unauthorized.localizedDescription + } catch { + remoteOperationMessage = error.localizedDescription + } + } + + func connectT3ConnectEnvironment( + _ environment: T3ConnectEnvironment, + replacingDirectPath: Bool = false + ) async throws { + guard let client = t3ConnectClient else { throw T3ConnectError.notImported } + if !replacingDirectPath, profileStore.load().contains(where: { + $0.environmentID == environment.environmentID && $0.source == .direct + }) { + return + } + let result = try await client.connect(environment) + if !replacingDirectPath { + try profileStore.upsert(result.profile) + t3ConnectEnabledStates[result.profile.environmentID] = result.profile.enabled + } + guard let endpoint = result.profile.directEndpoint else { + throw T3ConnectError.invalidResponse + } + coordinator.register( + profile: result.profile, + descriptor: result.descriptor, + endpoint: endpoint, + authorizer: DPoPHTTPAuthorizer( + accessToken: result.credential.accessToken, + signer: try await ensureDPoPSigner(allowsPrompt: false) + ) + ) + } + + func forgetT3Connect() async { + guard !isRemoteOperationRunning else { return } + isRemoteOperationRunning = true + defer { isRemoteOperationRunning = false } + do { + try await t3ConnectClient?.forget() + t3ConnectClient = nil + t3ConnectEnvironments = [] + for profile in profileStore.load() where profile.source == .t3Connect { + try profileStore.remove(profile.environmentID) + t3ConnectEnabledStates.removeValue(forKey: profile.environmentID) + coordinator.remove(profile.environmentID) + snapshotsByEnvironment.removeValue(forKey: profile.environmentID) + } + rebuildFlattenedWorld() + } catch { + remoteOperationMessage = error.localizedDescription + } + } + + private func configureT3Connect() { + refreshT3ConnectDetection() + guard let configuration = t3ConnectConfiguration, + let signer = dpopSigner, + let document = try? remoteVault.document(), + document.importedT3Connect != nil + else { + return + } + t3ConnectClient = T3ConnectClient( + configuration: configuration, + vault: remoteVault, + signer: signer + ) + Task { [weak self] in + try? await self?.refreshT3ConnectEnvironments(connectEnabled: true) + } + } + + private func purgeImportedT3ConnectAfterLogout() { + try? remoteVault.forgetT3Connect() + t3ConnectClient = nil + t3ConnectEnvironments = [] + t3ConnectSessionUpdateAvailable = false + let profiles = profileStore.load() + for profile in profiles where profile.source == .t3Connect { + try? profileStore.remove(profile.environmentID) + coordinator.remove(profile.environmentID) + snapshotsByEnvironment.removeValue(forKey: profile.environmentID) + } + let connectSnapshots = snapshotsByEnvironment.values.filter { + $0.activeAccessPath == .t3Connect + } + for snapshot in connectSnapshots { + guard let direct = profiles.first(where: { + $0.environmentID == snapshot.profile.environmentID && $0.source == .direct + }) else { + continue + } + installPlaceholder(profile: direct, state: .offline("T3 Connect signed out")) + } + rebuildFlattenedWorld() + Task { await restoreRemoteMachines() } + } + + private func updateAccessPathHealth(_ snapshot: EnvironmentSnapshot) async { + let environmentID = snapshot.profile.environmentID + guard snapshot.profile.source == .direct else { + if snapshot.profile.source == .t3Connect { + directFailureCounts[environmentID] = 0 + } + return + } + switch snapshot.connectionState { + case .connected, .connecting: + directFailureCounts[environmentID] = 0 + case .offline: + directFailureCounts[environmentID, default: 0] += 1 + if directFailureCounts[environmentID, default: 0] >= 2 { + await attemptConnectFallback(environmentID) + } + case .needsPairing, .unauthorized: + await attemptConnectFallback(environmentID) + case .credentialLocked, .incompatible: + break + } + } + + private func attemptConnectFallback(_ environmentID: EnvironmentID) async { + await recoverT3ConnectEnvironment( + environmentID, + replacingDirectPath: true, + resetPathCounters: true + ) + } + + private func repairT3ConnectEnvironment(_ environmentID: EnvironmentID) async { + let hasDirect = profileStore.load().contains { + $0.environmentID == environmentID && $0.source == .direct + } + await recoverT3ConnectEnvironment( + environmentID, + replacingDirectPath: hasDirect, + resetPathCounters: false + ) + } + + private func recoverT3ConnectEnvironment( + _ environmentID: EnvironmentID, + replacingDirectPath: Bool, + resetPathCounters: Bool + ) async { + guard t3ConnectClient != nil, + !connectFallbacksInFlight.contains(environmentID) + else { + return + } + connectFallbacksInFlight.insert(environmentID) + defer { connectFallbacksInFlight.remove(environmentID) } + do { + if !t3ConnectEnvironments.contains(where: { $0.environmentID == environmentID }) { + try await refreshT3ConnectEnvironments(connectEnabled: false) + } + guard let environment = t3ConnectEnvironments.first(where: { + $0.environmentID == environmentID + }) else { + return + } + try await connectT3ConnectEnvironment( + environment, + replacingDirectPath: replacingDirectPath + ) + if resetPathCounters { + directFailureCounts[environmentID] = 0 + directProbeSuccesses[environmentID] = 0 + } + } catch T3ConnectError.unauthorized { + purgeImportedT3ConnectAfterLogout() + } catch { + // The current path remains visible as offline. A future poll or + // maintenance refresh retries without blocking local monitoring. + } + } + + private func probePreferredDirectPaths() async { + guard let signer = dpopSigner, + let document = try? remoteVault.document() + else { + return + } + let profiles = profileStore.load() + for snapshot in snapshotsByEnvironment.values + where snapshot.activeAccessPath == .t3Connect + { + let environmentID = snapshot.profile.environmentID + guard let direct = profiles.first(where: { + $0.environmentID == environmentID + && $0.source == .direct + && $0.enabled + }), + let endpoint = direct.directEndpoint, + let credential = document.environmentCredentials[environmentID.rawValue], + !credential.needsRefresh + else { + directProbeSuccesses[environmentID] = 0 + continue + } + let client = T3HTTPClient( + endpoint: endpoint, + authorizer: DPoPHTTPAuthorizer( + accessToken: credential.accessToken, + signer: signer + ) + ) + do { + try await client.verifySession() + let descriptor = try await client.fetchEnvironment() + guard descriptor.environmentId == environmentID.rawValue else { + directProbeSuccesses[environmentID] = 0 + continue + } + directProbeSuccesses[environmentID, default: 0] += 1 + if directProbeSuccesses[environmentID, default: 0] >= 2 { + coordinator.register( + profile: direct, + descriptor: descriptor, + endpoint: endpoint, + authorizer: DPoPHTTPAuthorizer( + accessToken: credential.accessToken, + signer: signer + ) + ) + directProbeSuccesses[environmentID] = 0 + } + } catch { + directProbeSuccesses[environmentID] = 0 + } + } + } + + /// Called on wake, network restoration, and the menu-bar reconnect command. + /// Local polling is independent, so a Connect outage cannot suppress this. + func handleConnectivityAvailable() { + if connectionState == .unauthorized || needsOnboarding { + bootstrap() + } else { + handleConnectivityRestored() + } + } + + func handleConnectivityRestored() { + coordinator.reconnect() + Task { await performRemoteMaintenance() } + } + + /// Refreshes Connect inventory and probes any direct path currently using a + /// relay fallback. AppDelegate runs this every 60 seconds. + func performRemoteMaintenance() async { + await refreshT3ConnectDetectionNow() + if t3ConnectClient != nil { + do { + try await refreshT3ConnectEnvironments(connectEnabled: true) + } catch T3ConnectError.unauthorized { + purgeImportedT3ConnectAfterLogout() + } catch { + // Periodic maintenance is deliberately quiet. The explicit + // Refresh action surfaces its error in Settings. + } + } + await probePreferredDirectPaths() + } + + func isT3ConnectEnvironmentEnabled(_ environmentID: EnvironmentID) -> Bool { + t3ConnectEnabledStates[environmentID] ?? false + } + + func setT3ConnectEnvironmentEnabled( + _ environment: T3ConnectEnvironment, + enabled: Bool + ) { + if let existing = profileStore.load().first(where: { + $0.environmentID == environment.environmentID + }) { + setMachineEnabled(existing.environmentID, enabled: enabled) + return + } + let profile = EnvironmentProfile( + environmentID: environment.environmentID, + label: environment.label, + directEndpoint: environment.endpoint, + source: .t3Connect, + enabled: enabled + ) + do { + try profileStore.upsert(profile) + t3ConnectEnabledStates[environment.environmentID] = enabled + if enabled { + Task { + do { + try await connectT3ConnectEnvironment(environment) + } catch { + remoteOperationMessage = error.localizedDescription + installPlaceholder(profile: profile, state: .offline(nil)) + } + } + } else { + installPlaceholder(profile: profile, state: .offline("Disabled")) + } + } catch { + remoteOperationMessage = error.localizedDescription + } + } + + /// Local loopback always wins over a relay copy of this Mac. Remote relay + /// profiles keep their own persisted enable state. + private func normalizeT3ConnectProfiles(localEnvironmentID: EnvironmentID) { + for profile in profileStore.load() where profile.source == .t3Connect { + guard profile.environmentID == localEnvironmentID else { continue } + try? profileStore.remove(profile.environmentID) + t3ConnectEnabledStates.removeValue(forKey: profile.environmentID) + try? remoteVault.removeEnvironment(profile.environmentID) + coordinator.remove(profile.environmentID) + snapshotsByEnvironment.removeValue(forKey: profile.environmentID) + } + } + + private func ensureDPoPSigner(allowsPrompt: Bool) async throws -> DPoPSigner { + if let dpopSigner { return dpopSigner } + let document = allowsPrompt ? try remoteVault.unlock() : try remoteVault.loadWithoutPrompt() + let signer = try DPoPSigner(privateKeyRawRepresentation: document.dpopPrivateKey) + dpopSigner = signer + if document.dpopPrivateKey == nil { + let raw = await signer.privateKeyRawRepresentation + try remoteVault.update { $0.dpopPrivateKey = raw } + } + return signer + } + func setHovering(_ hovering: Bool) { isHovering = hovering recomputePresentation(userInitiated: true) @@ -561,10 +1492,7 @@ final class AgentStore { func expand() { presentation = .expanded - transport?.setExpanded(true) - if let id = focusedThread?.id { - subscribeDetail(id) - } + coordinator.setExpanded(true) syncClock() } @@ -575,73 +1503,266 @@ final class AgentStore { } func respondToApproval(_ approval: PendingApproval, decision: ApprovalDecision) { - guard let threadId = focusedThread?.id else { return } + guard let scoped = focusedScopedThread else { return } if isDemoRunning { pendingApprovals.removeAll { $0.requestId == approval.requestId } finishDemoPrompt() return } - answeredRequestIds.insert(approval.requestId) + answeredRequestIds.insert(scopedRequestKey(approval.requestId, thread: scoped)) pendingApprovals.removeAll { $0.requestId == approval.requestId } let command = DispatchCommand.approvalRespond( commandId: UUID().uuidString, - threadId: threadId, + threadId: scoped.threadID, requestId: approval.requestId, decision: decision, createdAt: ISO8601Parsing.nowString() ) Task { - try? await transport?.dispatch(command) + try? await coordinator.dispatch(command, to: scoped.environmentID) } } func respondToUserInput(_ input: PendingUserInput, answers: [String: JSONValue]) { - guard let threadId = focusedThread?.id else { return } + guard let scoped = focusedScopedThread else { return } if isDemoRunning { pendingUserInputs.removeAll { $0.requestId == input.requestId } finishDemoPrompt() return } - answeredRequestIds.insert(input.requestId) + answeredRequestIds.insert(scopedRequestKey(input.requestId, thread: scoped)) pendingUserInputs.removeAll { $0.requestId == input.requestId } let command = DispatchCommand.userInputRespond( commandId: UUID().uuidString, - threadId: threadId, + threadId: scoped.threadID, requestId: input.requestId, answers: answers, createdAt: ISO8601Parsing.nowString() ) Task { - try? await transport?.dispatch(command) + try? await coordinator.dispatch(command, to: scoped.environmentID) } } func interruptTurn() { - guard let thread = focusedThread else { return } + guard let thread = focusedThread, let scoped = focusedScopedThread else { return } let command = DispatchCommand.turnInterrupt( commandId: UUID().uuidString, - threadId: thread.id, + threadId: scoped.threadID, turnId: thread.latestTurn?.turnId, createdAt: ISO8601Parsing.nowString() ) Task { - try? await transport?.dispatch(command) + try? await coordinator.dispatch(command, to: scoped.environmentID) } } // MARK: - Private - private func applyShell(_ snapshot: ShellSnapshot) async { + private func observeCoordinator() { + coordinatorTask?.cancel() + coordinatorTask = Task { [weak self] in + guard let self else { return } + for await event in coordinator.events { + await self.applyEnvironmentEvent(event) + } + } + } + + private func applyEnvironmentEvent(_ event: EnvironmentEvent) async { + switch event { + case let .snapshot(snapshot): + retainRemoteCompletionTransition( + from: snapshotsByEnvironment[snapshot.profile.environmentID], + to: snapshot + ) + snapshotsByEnvironment[snapshot.profile.environmentID] = snapshot + if snapshot.profile.source == .local { + connectionState = legacyConnectionState(snapshot.connectionState) + if snapshot.connectionState == .unauthorized { + needsOnboarding = true + onboardingMessage = "Session expired. Paste a new bearer token." + } + } + Task { [weak self] in + guard let self else { return } + await updateAccessPathHealth(snapshot) + if snapshot.profile.source == .t3Connect, + snapshot.connectionState == .unauthorized + { + await repairT3ConnectEnvironment(snapshot.profile.environmentID) + } + } + rebuildFlattenedWorld() + await applyCombinedShell(changedEnvironment: snapshot.profile.environmentID) + case let .detail(scoped, detail): + await applyScopedDetail(detail, scoped: scoped) + case let .removed(environmentID): + snapshotsByEnvironment.removeValue(forKey: environmentID) + retainedRemoteCompletions.removeValue(forKey: environmentID) + knownProjectsByEnvironment.removeValue(forKey: environmentID) + rebuildFlattenedWorld() + await applyCombinedShell(changedEnvironment: environmentID) + } + } + + /// T3 Connect's active shell can jump directly from "running" to absent. + /// Turn that disappearance into a stable, machine-scoped completion card. + /// This only runs after the first real snapshot, so historical remote work + /// never floods the notch when a machine first connects. + private func retainRemoteCompletionTransition( + from previous: EnvironmentSnapshot?, + to incoming: EnvironmentSnapshot + ) { + let environmentID = incoming.profile.environmentID + guard incoming.activeAccessPath != .local, + reviewBaselines.contains(environmentID), + let previousShell = previous?.shell, + let incomingShell = incoming.shell + else { + return + } + + var knownProjects = knownProjectsByEnvironment[environmentID] ?? [:] + for project in previousShell.projects + incomingShell.projects { + knownProjects[project.id] = project + } + knownProjectsByEnvironment[environmentID] = knownProjects + + var retained = retainedRemoteCompletions[environmentID] ?? [:] + let previousByID = Dictionary( + previousShell.threads.map { ($0.id, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + let incomingByID = Dictionary( + incomingShell.threads.map { ($0.id, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + + // A new run in the same thread supersedes an older retained result. + for thread in incomingShell.threads { + switch resolveThreadAwarenessPhase(thread) { + case .completed: + if shouldRetainRemoteCompletion(thread, environmentID: environmentID) { + retained[thread.id] = thread + } else { + retained[thread.id] = nil + } + case .running, .starting, .waitingForApproval, .waitingForInput: + retained[thread.id] = nil + default: + if let prior = previousByID[thread.id], + isActiveWork(resolveThreadAwarenessPhase(prior)) + { + let completion = completedCopy( + of: thread, + at: incomingShell.updatedAt, + sequence: incomingShell.snapshotSequence + ) + if shouldRetainRemoteCompletion( + completion, + environmentID: environmentID + ) { + retained[thread.id] = completion + } + } + } + } + + for thread in previousShell.threads where incomingByID[thread.id] == nil { + let completion: ThreadShell? + switch resolveThreadAwarenessPhase(thread) { + case .completed: + completion = thread + case .running, .starting, .waitingForApproval, .waitingForInput: + completion = completedCopy( + of: thread, + at: incomingShell.updatedAt, + sequence: incomingShell.snapshotSequence + ) + default: + completion = nil + } + if let completion, + shouldRetainRemoteCompletion(completion, environmentID: environmentID) + { + retained[thread.id] = completion + } + } + + retainedRemoteCompletions[environmentID] = retained + } + + private func isActiveWork(_ phase: AgentAwarenessPhase?) -> Bool { + switch phase { + case .running, .starting, .waitingForApproval, .waitingForInput: + true + default: + false + } + } + + private func completedCopy( + of thread: ThreadShell, + at completedAt: String, + sequence: Int + ) -> ThreadShell { + var completion = thread + if var turn = completion.latestTurn { + turn.state = "completed" + turn.completedAt = turn.completedAt ?? completedAt + completion.latestTurn = turn + } else { + completion.latestTurn = LatestTurn( + turnId: completion.session?.activeTurnId + ?? "remote-completion-\(sequence)-\(thread.id)", + state: "completed", + completedAt: completedAt + ) + } + if var session = completion.session { + session.status = "idle" + session.activeTurnId = nil + session.updatedAt = completedAt + completion.session = session + } + completion.updatedAt = completedAt + completion.settledAt = nil + completion.hasPendingApprovals = false + completion.hasPendingUserInput = false + return completion + } + + private func shouldRetainRemoteCompletion( + _ rawThread: ThreadShell, + environmentID: EnvironmentID + ) -> Bool { + guard rawThread.archivedAt == nil else { return false } + var scopedThread = rawThread + scopedThread.id = ScopedThreadID( + environmentID: environmentID, + threadID: rawThread.id + ).storageKey + return !reviewStore.contains(completionKey(for: scopedThread)) + } + + private func applyCombinedShell(changedEnvironment: EnvironmentID) async { // The welcome tour owns the panel while it runs. guard !isDemoRunning else { return } - projects = snapshot.projects - threads = snapshot.threads.filter { $0.archivedAt == nil } // Everything already finished when the notch started counts as seen, // otherwise the first snapshot would pin every historical thread. - if !hasReviewBaseline { - hasReviewBaseline = true - for thread in threads where resolveThreadAwarenessPhase(thread) == .completed { + // A connecting snapshot has no shell yet. Waiting for the first real + // shell prevents a remote machine's historical completions from being + // mistaken for brand-new Done notifications on the following event. + if !reviewBaselines.contains(changedEnvironment), + snapshotsByEnvironment[changedEnvironment]?.shell != nil + { + reviewBaselines.insert(changedEnvironment) + for thread in threads + where environmentID(for: thread) == changedEnvironment + && resolveThreadAwarenessPhase(thread) == .completed + { reviewStore.insert(completionKey(for: thread)) } } @@ -679,19 +1800,14 @@ final class AgentStore { private func replaceFocusedThread(with threadId: String?) { guard let threadId else { focusedThreadId = nil - transport?.setFocusedThread(nil) - detailTask?.cancel() - detailTask = nil - subscribedThreadId = nil + coordinator.setFocusedThread(nil) clearFocusedDetail() + syncFocusedEnvironment() return } guard threadId != focusedThreadId else { - transport?.setFocusedThread(threadId) - // Snapshots land every 800ms while an agent works; subscribing is - // idempotent so the detail stream survives them. - subscribeDetail(threadId) + coordinator.setFocusedThread(scopedThreads[threadId]) return } @@ -699,9 +1815,8 @@ final class AgentStore { // Detail arrives a poll later; drop the old thread's data so the card // never shows another agent's questions, plan, activity, or context. clearFocusedDetail() - - transport?.setFocusedThread(threadId) - subscribeDetail(threadId) + syncFocusedEnvironment() + coordinator.setFocusedThread(scopedThreads[threadId]) } private func clearFocusedDetail() { @@ -714,29 +1829,139 @@ final class AgentStore { recentActivity = [] } - /// Follows one thread's detail stream. Asking for the thread already being - /// followed is a no-op: `threadDetail(_:)` hands out a fresh stream and ends - /// the previous one, so re-subscribing on a timer would keep killing the - /// stream before any detail arrived. - private func subscribeDetail(_ threadId: String) { - guard let transport else { return } - guard threadId != subscribedThreadId else { return } - detailTask?.cancel() - subscribedThreadId = threadId - detailTask = Task { [weak self] in - guard let self else { return } - for await detail in transport.threadDetail(threadId) { - await self.applyDetail(detail) + private func rebuildFlattenedWorld() { + var nextProjects: [ProjectShell] = [] + var nextThreads: [ThreadShell] = [] + var nextScopedThreads: [String: ScopedThreadID] = [:] + var nextScopedProjects: [String: ScopedProjectID] = [:] + + let ordered = snapshotsByEnvironment.values.sorted { + let left = sourcePriority($0.activeAccessPath) + let right = sourcePriority($1.activeAccessPath) + if left != right { return left < right } + return $0.profile.label.localizedStandardCompare($1.profile.label) == .orderedAscending + } + for snapshot in ordered { + guard snapshot.profile.enabled, + snapshot.connectionState == .connected, + let shell = snapshot.shell + else { + continue + } + let environmentID = snapshot.profile.environmentID + let retained = retainedRemoteCompletions[environmentID] ?? [:] + let shellThreadIDs = Set(shell.threads.map(\.id)) + let retainedThreads = retained.values.filter { + !shellThreadIDs.contains($0.id) + && shouldRetainRemoteCompletion($0, environmentID: environmentID) } - // The stream ended (transport stopped or replaced); let the next - // snapshot re-subscribe instead of going quiet for good. - self.detailStreamEnded(threadId) + let retainedProjectIDs = Set(retainedThreads.map(\.projectId)) + var rawProjects = shell.projects + let shellProjectIDs = Set(rawProjects.map(\.id)) + for projectID in retainedProjectIDs where !shellProjectIDs.contains(projectID) { + if let project = knownProjectsByEnvironment[environmentID]?[projectID] { + rawProjects.append(project) + } + } + for rawProject in rawProjects { + let scoped = ScopedProjectID( + environmentID: environmentID, + projectID: rawProject.id + ) + var project = rawProject + project.id = scoped.storageKey + nextScopedProjects[project.id] = scoped + nextProjects.append(project) + } + for shellThread in shell.threads where shellThread.archivedAt == nil { + let rawThread: ThreadShell + if resolveThreadAwarenessPhase(shellThread) == nil, + let retainedCompletion = retained[shellThread.id] + { + rawThread = retainedCompletion + } else { + rawThread = shellThread + } + let scoped = ScopedThreadID( + environmentID: environmentID, + threadID: rawThread.id + ) + let project = ScopedProjectID( + environmentID: environmentID, + projectID: rawThread.projectId + ) + var thread = rawThread + thread.id = scoped.storageKey + thread.projectId = project.storageKey + nextScopedThreads[thread.id] = scoped + nextThreads.append(thread) + } + for rawThread in retainedThreads { + let scoped = ScopedThreadID( + environmentID: environmentID, + threadID: rawThread.id + ) + let project = ScopedProjectID( + environmentID: environmentID, + projectID: rawThread.projectId + ) + var thread = rawThread + thread.id = scoped.storageKey + thread.projectId = project.storageKey + nextScopedThreads[thread.id] = scoped + nextThreads.append(thread) + } + } + projects = nextProjects + threads = nextThreads + scopedThreads = nextScopedThreads + scopedProjects = nextScopedProjects + machines = ordered + syncFocusedEnvironment() + } + + private func syncFocusedEnvironment() { + if let environmentID = focusedThreadId.flatMap({ scopedThreads[$0]?.environmentID }) { + environment = snapshotsByEnvironment[environmentID]?.descriptor + } else if let localEnvironmentID { + environment = snapshotsByEnvironment[localEnvironmentID]?.descriptor } } - private func detailStreamEnded(_ threadId: String) { - guard subscribedThreadId == threadId else { return } - subscribedThreadId = nil + private func legacyConnectionState( + _ state: EnvironmentConnectionState + ) -> ConnectionState { + switch state { + case .connecting: .connecting + case .connected: .connected + case .offline, .credentialLocked, .incompatible: .disconnected + case .unauthorized, .needsPairing: .unauthorized + } + } + + private func sourcePriority(_ source: EnvironmentSource) -> Int { + switch source { + case .local: 0 + case .direct: 1 + case .t3Connect: 2 + } + } + + private func scopedRequestKey(_ requestID: String, thread: ScopedThreadID) -> String { + "\(thread.storageKey):\(requestID)" + } + + private func applyScopedDetail( + _ snapshot: ThreadDetailSnapshot, + scoped: ScopedThreadID + ) async { + var displaySnapshot = snapshot + displaySnapshot.thread.id = scoped.storageKey + displaySnapshot.thread.projectId = ScopedProjectID( + environmentID: scoped.environmentID, + projectID: snapshot.thread.projectId + ).storageKey + await applyDetail(displaySnapshot, scoped: scoped) } // MARK: - Milestones @@ -774,6 +1999,7 @@ final class AgentStore { .sorted { $0.updatedAt > $1.updatedAt } .prefix(12) .compactMap { thread in + guard environmentID(for: thread) == localEnvironmentID else { return nil } guard let branch = thread.branch?.nilIfBlank else { return nil } // The project checkout is preferred over the worktree here: refs // are shared, so one root per project keeps the git calls down. @@ -831,7 +2057,7 @@ final class AgentStore { // Milestones land when the panel is idle in the notch, so open it — // otherwise the one moment worth seeing happens off-screen. presentation = .expanded - transport?.setExpanded(true) + coordinator.setExpanded(true) syncClock() celebrationTask = Task { [weak self] in try? await Task.sleep(for: .seconds(milestone.duration)) @@ -848,15 +2074,28 @@ final class AgentStore { } } - private func applyDetail(_ snapshot: ThreadDetailSnapshot) async { + private func applyDetail( + _ snapshot: ThreadDetailSnapshot, + scoped: ScopedThreadID? = nil + ) async { guard !isDemoRunning else { return } guard snapshot.thread.id == focusedThreadId else { return } threadDetail = snapshot.thread let activities = snapshot.thread.activities pendingApprovals = derivePendingApprovals(from: activities) - .filter { !answeredRequestIds.contains($0.requestId) } + .filter { + guard let scoped else { return !answeredRequestIds.contains($0.requestId) } + return !answeredRequestIds.contains( + scopedRequestKey($0.requestId, thread: scoped) + ) + } pendingUserInputs = derivePendingUserInputs(from: activities) - .filter { !answeredRequestIds.contains($0.requestId) } + .filter { + guard let scoped else { return !answeredRequestIds.contains($0.requestId) } + return !answeredRequestIds.contains( + scopedRequestKey($0.requestId, thread: scoped) + ) + } let previousPlan = plan plan = deriveActivePlanState( from: activities, @@ -912,7 +2151,7 @@ final class AgentStore { // turn lands or a branch merges is exactly when nobody is hovering. if celebration != nil { presentation = .expanded - transport?.setExpanded(true) + coordinator.setExpanded(true) return } @@ -926,13 +2165,13 @@ final class AgentStore { forceAttention || (needsAttention && presentation != .expanded && !userInitiated) { presentation = .attention - transport?.setExpanded(true) + coordinator.setExpanded(true) return } if isHovering { presentation = active.isEmpty && walkthrough == nil ? .pill : .expanded - transport?.setExpanded(presentation == .expanded) + coordinator.setExpanded(presentation == .expanded) return } @@ -941,7 +2180,7 @@ final class AgentStore { // screen so the walkthrough is pointing at something. if let walkthrough { presentation = walkthrough.wantsPanel ? .expanded : .pill - transport?.setExpanded(presentation == .expanded) + coordinator.setExpanded(presentation == .expanded) return } @@ -951,7 +2190,7 @@ final class AgentStore { if active.isEmpty { presentation = .hidden - transport?.setExpanded(false) + coordinator.setExpanded(false) return } @@ -960,7 +2199,7 @@ final class AgentStore { } presentation = .pill - transport?.setExpanded(false) + coordinator.setExpanded(false) } func playAttentionSound() { diff --git a/Sources/T3Notch/AppDelegate.swift b/Sources/T3Notch/AppDelegate.swift index 79b6043..546befa 100644 --- a/Sources/T3Notch/AppDelegate.swift +++ b/Sources/T3Notch/AppDelegate.swift @@ -1,4 +1,6 @@ import AppKit +import Network +import os import SwiftUI @MainActor @@ -12,6 +14,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Hidden until an update is worth mentioning. private var updateItem: NSMenuItem? private var statusItemBadged = false + private let networkMonitor = NWPathMonitor() + private let networkMonitorQueue = DispatchQueue( + label: "gg.t3tools.t3notch.network-monitor" + ) + private let networkSatisfaction = OSAllocatedUnfairLock(initialState: true) + private var remoteRefreshTimer: Timer? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) @@ -39,6 +47,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { installStatusItem() installMainMenu() store.bootstrap() + startConnectivityObservers() if settings.values.automaticUpdates { updater.start() } @@ -62,6 +71,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + func applicationWillTerminate(_ notification: Notification) { + networkMonitor.cancel() + remoteRefreshTimer?.invalidate() + remoteRefreshTimer = nil + NSWorkspace.shared.notificationCenter.removeObserver(self) + } + + private func startConnectivityObservers() { + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(macDidWake), + name: NSWorkspace.didWakeNotification, + object: nil + ) + let satisfaction = networkSatisfaction + networkMonitor.pathUpdateHandler = { [weak self, satisfaction] path in + let isSatisfied = path.status == .satisfied + let restored = satisfaction.withLock { wasSatisfied in + defer { wasSatisfied = isSatisfied } + return isSatisfied && !wasSatisfied + } + guard restored else { return } + Task { @MainActor in + self?.store.handleConnectivityAvailable() + } + } + networkMonitor.start(queue: networkMonitorQueue) + remoteRefreshTimer = Timer.scheduledTimer( + withTimeInterval: 60, + repeats: true + ) { [weak self] _ in + Task { @MainActor in + await self?.store.performRemoteMaintenance() + } + } + } + + @objc private func macDidWake() { + store.handleConnectivityAvailable() + } + private func installStatusItem() { let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let button = item.button { @@ -250,7 +300,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func reconnect() { - store.bootstrap() + store.handleConnectivityAvailable() } @objc private func quit() { diff --git a/Sources/T3Notch/MachineSettingsView.swift b/Sources/T3Notch/MachineSettingsView.swift new file mode 100644 index 0000000..bd366a7 --- /dev/null +++ b/Sources/T3Notch/MachineSettingsView.swift @@ -0,0 +1,697 @@ +import SwiftUI +import T3NotchCore + +struct MachineSettingsCard: View { + private enum PresentedSheet: String, Identifiable { + case pairing + case connectImport + case connectPermissionWarning + + var id: String { rawValue } + } + + @Bindable var store: AgentStore + let onShowQuickStart: () -> Void + + @State private var presentedSheet: PresentedSheet? + @State private var removalTarget: EnvironmentSnapshot? + @State private var copiedPermissionFix = false + + var body: some View { + SettingsCard("Machines") { + if let local = store.machines.first(where: { $0.profile.source == .local }) { + machineRow(local, isLocal: true) + } else { + SettingsRow( + "This Mac", + detail: store.endpoint.baseURL.absoluteString + ) { + PillButton("Reconnect") { store.bootstrap() } + } + } + + ForEach(store.remoteMachines, id: \.profile.environmentID) { machine in + SettingsDivider() + machineRow(machine, isLocal: false) + } + + SettingsDivider() + SettingsRow( + "Add a machine", + detail: "Pair over HTTPS, Tailscale, or a trusted local network." + ) { + PillButton("Add…") { presentedSheet = .pairing } + } + + if store.remoteVaultLocked { + SettingsDivider() + SettingsRow( + "Remote credentials locked", + detail: "Unlock once to reconnect saved machines.", + detailIsProblem: true + ) { + PillButton("Unlock") { + Task { await store.unlockRemoteCredentials() } + } + .disabled(store.isRemoteOperationRunning) + } + } + + if store.showsT3Connect { + SettingsDivider() + t3ConnectRow + if store.hasImportedT3Connect { + ForEach(unregisteredT3ConnectEnvironments) { environment in + SettingsDivider() + t3ConnectEnvironmentRow(environment) + } + } + } + + if let message = store.remoteOperationMessage?.nilIfBlank { + SettingsDivider() + Text(message) + .font(.system(size: 10.5, design: .rounded)) + .foregroundStyle(Color.orange.opacity(0.9)) + .fixedSize(horizontal: false, vertical: true) + .accessibilityLabel("Remote connection problem: \(message)") + } + + SettingsDivider() + SettingsRow( + "Quick start", + detail: "The first-launch walkthrough, with a connection test." + ) { + PillButton("Show", action: onShowQuickStart) + } + } + .sheet(item: $presentedSheet) { sheet in + switch sheet { + case .pairing: + RemotePairingSheet(store: store) + case .connectImport: + T3ConnectImportSheet(store: store) + case .connectPermissionWarning: + T3ConnectPermissionWarningSheet { + store.copyT3ConnectPermissionFix() + copiedPermissionFix = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + copiedPermissionFix = false + } + } + } + } + .confirmationDialog( + "Remove this machine?", + isPresented: Binding( + get: { removalTarget != nil }, + set: { if !$0 { removalTarget = nil } } + ), + presenting: removalTarget + ) { machine in + Button("Remove \(machine.profile.label)", role: .destructive) { + store.removeMachine(machine.profile.environmentID) + removalTarget = nil + } + Button("Cancel", role: .cancel) { + removalTarget = nil + } + } message: { machine in + Text( + "T3Notch will forget its saved endpoint and session. " + + "Nothing is removed from \(machine.profile.label)." + ) + } + .onAppear { + store.refreshT3ConnectDetection() + } + } + + @ViewBuilder + private func machineRow(_ machine: EnvironmentSnapshot, isLocal: Bool) -> some View { + SettingsRow( + machine.descriptor?.label?.nilIfBlank ?? machine.profile.label, + detail: machineDetail(machine), + detailIsProblem: machine.connectionState.isProblem + ) { + VStack(alignment: .trailing, spacing: 6) { + HStack(spacing: 6) { + AccessBadge(source: machine.activeAccessPath) + connectionBadge(machine.connectionState) + } + HStack(spacing: 6) { + if !isLocal { + Toggle( + "", + isOn: Binding( + get: { machine.profile.enabled }, + set: { + store.setMachineEnabled( + machine.profile.environmentID, + enabled: $0 + ) + } + ) + ) + .toggleStyle(NotchToggleStyle()) + .labelsHidden() + .accessibilityLabel( + machine.profile.enabled + ? "Disable \(machine.profile.label)" + : "Enable \(machine.profile.label)" + ) + } + PillButton(actionTitle(machine.connectionState)) { + if isLocal { + store.bootstrap() + } else { + switch machine.connectionState { + case .needsPairing, .unauthorized: + presentedSheet = .pairing + case .credentialLocked: + Task { await store.unlockRemoteCredentials() } + default: + store.reconnectMachine(machine.profile.environmentID) + } + } + } + if !isLocal { + Button { + removalTarget = machine + } label: { + Image(systemName: "trash") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white.opacity(0.48)) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove \(machine.profile.label)") + } + } + } + } + } + + private var t3ConnectRow: some View { + SettingsRow( + "T3 Connect", + detail: store.hasImportedT3Connect + ? connectDetail + : store.t3ConnectImportDetail, + detailIsProblem: !store.hasImportedT3Connect + && store.t3ConnectImportHasProblem + ) { + HStack(spacing: 6) { + if store.hasImportedT3Connect { + if store.t3ConnectSessionUpdateAvailable { + PillButton("Import again…") { + presentedSheet = .connectImport + } + .disabled(store.isRemoteOperationRunning) + } + PillButton(store.isRemoteOperationRunning ? "Refreshing…" : "Refresh") { + Task { await store.refreshT3Connect() } + } + .disabled(store.isRemoteOperationRunning) + PillButton("Forget") { + Task { await store.forgetT3Connect() } + } + .disabled(store.isRemoteOperationRunning) + } else if store.canImportT3Connect { + PillButton("Import…") { + presentedSheet = .connectImport + } + .disabled(store.isRemoteOperationRunning) + } else if store.canRepairT3ConnectPermissions { + PillButton(copiedPermissionFix ? "Copied" : "Copy fix") { + presentedSheet = .connectPermissionWarning + } + .disabled(store.isRemoteOperationRunning) + PillButton("Refresh") { + store.refreshT3ConnectDetection() + } + } else { + PillButton("Refresh") { + store.refreshT3ConnectDetection() + } + } + } + } + } + + private var unregisteredT3ConnectEnvironments: [T3ConnectEnvironment] { + let registered = Set(store.machines.map(\.profile.environmentID)) + return store.t3ConnectEnvironments.filter { + !registered.contains($0.environmentID) + } + } + + private func t3ConnectEnvironmentRow( + _ environment: T3ConnectEnvironment + ) -> some View { + let existing = store.machines.first { + $0.profile.environmentID == environment.environmentID + } + return SettingsRow( + environment.label, + detail: existing.map(machineDetail) + ?? environment.endpoint?.baseURL.absoluteString + ?? "Linked through T3 Connect." + ) { + HStack(spacing: 6) { + AccessBadge(source: existing?.activeAccessPath ?? .t3Connect) + Toggle( + "", + isOn: Binding( + get: { + store.isT3ConnectEnvironmentEnabled( + environment.environmentID + ) + }, + set: { + store.setT3ConnectEnvironmentEnabled( + environment, + enabled: $0 + ) + } + ) + ) + .toggleStyle(NotchToggleStyle()) + .labelsHidden() + .accessibilityLabel( + "Enable \(environment.label) through T3 Connect" + ) + } + } + } + + private var connectDetail: String { + let count = store.t3ConnectEnvironments.count + let detail = count == 0 + ? "Imported. No linked machines were returned yet." + : "\(count) linked \(count == 1 ? "machine" : "machines")." + return store.t3ConnectSessionUpdateAvailable + ? detail + " New T3 Code session available." + : detail + } + + private func machineDetail(_ machine: EnvironmentSnapshot) -> String { + let platform = machine.descriptor?.platform?.displayName + let version = machine.descriptor?.serverVersion + let endpoint = machine.profile.directEndpoint?.baseURL.absoluteString + let summary = [platform, version, endpoint].compactMap { $0?.nilIfBlank } + let suffix = summary.isEmpty ? "" : " · " + summary.joined(separator: " · ") + return machine.connectionState.label + suffix + } + + private func actionTitle(_ state: EnvironmentConnectionState) -> String { + switch state { + case .needsPairing, .unauthorized: "Re-pair" + case .credentialLocked: "Unlock" + default: "Reconnect" + } + } + + private func connectionBadge(_ state: EnvironmentConnectionState) -> some View { + HStack(spacing: 4) { + Circle() + .fill(state.color) + .frame(width: 5, height: 5) + Text(state.shortLabel) + .font(.system(size: 9, weight: .semibold, design: .rounded)) + } + .foregroundStyle(.white.opacity(0.64)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(state.label) + } +} + +private struct AccessBadge: View { + let source: EnvironmentSource + + var body: some View { + Text(label) + .font(.system(size: 9, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.66)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(.white.opacity(0.08))) + .accessibilityLabel("Access: \(label)") + } + + private var label: String { + switch source { + case .local: "Local" + case .direct: "Direct" + case .t3Connect: "T3 Connect" + } + } +} + +private struct RemotePairingSheet: View { + @Bindable var store: AgentStore + @Environment(\.dismiss) private var dismiss + + @State private var pairingURL = "" + @State private var host = "" + @State private var pairingCode = "" + @State private var advanced = false + @State private var allowsInsecureHTTP = false + @State private var errorMessage: String? + @State private var isSubmitting = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Add a machine") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + Text( + "Paste the pairing link created by T3 Code. " + + "The one-time credential is discarded after pairing." + ) + .font(.system(size: 12, design: .rounded)) + .foregroundStyle(.white.opacity(0.52)) + .fixedSize(horizontal: false, vertical: true) + } + + if advanced { + VStack(alignment: .leading, spacing: 8) { + fieldLabel("Backend URL") + TextField("https://mac-mini.example.ts.net", text: $host) + .textFieldStyle(.roundedBorder) + .accessibilityLabel("Backend URL") + fieldLabel("Pairing code") + SecureField("One-time pairing code", text: $pairingCode) + .textFieldStyle(.roundedBorder) + .accessibilityLabel("Pairing code") + } + } else { + VStack(alignment: .leading, spacing: 8) { + fieldLabel("Pairing link") + TextField("https://…/pair#token=…", text: $pairingURL) + .textFieldStyle(.roundedBorder) + .accessibilityLabel("T3 Code pairing link") + } + } + + Toggle("Enter a backend URL and code instead", isOn: $advanced) + .font(.system(size: 11.5, design: .rounded)) + .toggleStyle(.checkbox) + + Toggle( + "Allow unencrypted HTTP outside this Mac", + isOn: $allowsInsecureHTTP + ) + .font(.system(size: 11.5, design: .rounded)) + .toggleStyle(.checkbox) + + Text( + "Prefer HTTPS or Tailscale. Plain HTTP can expose agent details " + + "and responses to anyone able to observe the network." + ) + .font(.system(size: 10.5, design: .rounded)) + .foregroundStyle(.white.opacity(0.42)) + .fixedSize(horizontal: false, vertical: true) + + if let errorMessage { + Text(errorMessage) + .font(.system(size: 11, design: .rounded)) + .foregroundStyle(Color.orange.opacity(0.92)) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + PillButton("Cancel") { dismiss() } + Button { + submit() + } label: { + Text(isSubmitting ? "Pairing…" : "Add machine") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(Color(red: 0.21, green: 0.44, blue: 0.98))) + } + .buttonStyle(.plain) + .disabled(isSubmitting) + } + } + .padding(22) + .frame(width: 440) + .background(Color(red: 0.055, green: 0.06, blue: 0.075)) + .preferredColorScheme(.dark) + .interactiveDismissDisabled(isSubmitting) + .onDisappear { + pairingURL = "" + pairingCode = "" + } + } + + private func fieldLabel(_ text: String) -> some View { + Text(text) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.72)) + } + + private func submit() { + isSubmitting = true + errorMessage = nil + Task { + do { + try await store.pairRemoteMachine( + pairingURL: advanced ? nil : pairingURL, + host: advanced ? host : nil, + code: advanced ? pairingCode : nil, + allowsInsecureHTTP: allowsInsecureHTTP + ) + pairingURL = "" + pairingCode = "" + dismiss() + } catch { + errorMessage = error.localizedDescription + } + isSubmitting = false + } + } +} + +private struct T3ConnectImportSheet: View { + @Bindable var store: AgentStore + @Environment(\.dismiss) private var dismiss + @State private var importing = false + @State private var trustedMacConfirmed = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Import T3 Connect") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + + Text( + "T3Notch will ask macOS for access to T3 Code’s Safe Storage key, " + + "then copy the active sign-in into its own Keychain. " + + "It never changes T3 Code’s files, account, or linked machines." + ) + .font(.system(size: 12, design: .rounded)) + .foregroundStyle(.white.opacity(0.58)) + .fixedSize(horizontal: false, vertical: true) + + T3ConnectPrivateMacWarning( + trustedMacConfirmed: $trustedMacConfirmed + ) + + Text( + "This compatibility integration follows T3 Code’s current Electron " + + "and relay formats. A future T3 Code update may require importing again." + ) + .font(.system(size: 10.5, design: .rounded)) + .foregroundStyle(.white.opacity(0.42)) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Spacer() + PillButton("Cancel") { dismiss() } + Button { + importing = true + Task { + await store.importT3Connect() + importing = false + if store.hasImportedT3Connect { dismiss() } + } + } label: { + Text(importing ? "Importing…" : "Import") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(Color(red: 0.21, green: 0.44, blue: 0.98))) + } + .buttonStyle(.plain) + .disabled(importing || !trustedMacConfirmed) + .opacity(trustedMacConfirmed ? 1 : 0.45) + } + } + .padding(22) + .frame(width: 420) + .background(Color(red: 0.055, green: 0.06, blue: 0.075)) + .preferredColorScheme(.dark) + .interactiveDismissDisabled(importing) + } +} + +private struct T3ConnectPermissionWarningSheet: View { + @Environment(\.dismiss) private var dismiss + @State private var trustedMacConfirmed = false + let onCopy: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 7) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.orange) + .accessibilityHidden(true) + Text("Before changing permissions") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + } + + Text( + "The copied chmod 600 command restricts T3 Code’s session file " + + "to your current macOS account. It does not make a public " + + "or shared Mac safe." + ) + .font(.system(size: 12, design: .rounded)) + .foregroundStyle(.white.opacity(0.62)) + .fixedSize(horizontal: false, vertical: true) + } + + T3ConnectPrivateMacWarning( + trustedMacConfirmed: $trustedMacConfirmed + ) + + Text( + "T3Notch only copies the command. You choose whether to run it " + + "in Terminal, then return here and press Refresh." + ) + .font(.system(size: 10.5, design: .rounded)) + .foregroundStyle(.white.opacity(0.42)) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Spacer() + PillButton("Cancel") { dismiss() } + Button { + onCopy() + dismiss() + } label: { + Text("Copy command") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule().fill( + Color(red: 0.21, green: 0.44, blue: 0.98) + ) + ) + } + .buttonStyle(.plain) + .disabled(!trustedMacConfirmed) + .opacity(trustedMacConfirmed ? 1 : 0.45) + } + } + .padding(22) + .frame(width: 430) + .background(Color(red: 0.055, green: 0.06, blue: 0.075)) + .preferredColorScheme(.dark) + .accessibilityElement(children: .contain) + .accessibilityLabel("T3 Connect security warning") + } +} + +private struct T3ConnectPrivateMacWarning: View { + @Binding var trustedMacConfirmed: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + Text("Private, trusted Macs only") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.orange.opacity(0.95)) + + Text( + "Do not continue on a public, shared, borrowed, or otherwise " + + "untrusted Mac. Anyone who can use this macOS account may " + + "be able to access your T3 Connect session and linked agents." + ) + .font(.system(size: 11, design: .rounded)) + .foregroundStyle(.white.opacity(0.62)) + .fixedSize(horizontal: false, vertical: true) + + Toggle( + "This is a private Mac I control.", + isOn: $trustedMacConfirmed + ) + .font(.system(size: 11.5, weight: .medium, design: .rounded)) + .toggleStyle(.checkbox) + .accessibilityHint( + "Required before copying the permission command or importing T3 Connect." + ) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.orange.opacity(0.08)) + ) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.orange.opacity(0.22), lineWidth: 1) + } + } +} + +private extension EnvironmentConnectionState { + var label: String { + switch self { + case .connecting: "Connecting." + case .connected: "Connected." + case let .offline(reason): reason.map { "Offline: \($0)" } ?? "Offline." + case .unauthorized: "Session expired." + case .needsPairing: "Pairing required." + case .credentialLocked: "Credentials locked." + case let .incompatible(reason): "Incompatible: \(reason)" + } + } + + var shortLabel: String { + switch self { + case .connecting: "Connecting" + case .connected: "Online" + case .offline: "Offline" + case .unauthorized, .needsPairing: "Re-pair" + case .credentialLocked: "Locked" + case .incompatible: "Problem" + } + } + + var color: Color { + switch self { + case .connected: .green + case .connecting: .yellow + case .offline: .white.opacity(0.3) + case .unauthorized, .needsPairing, .credentialLocked, .incompatible: .orange + } + } + + var isProblem: Bool { + switch self { + case .unauthorized, .needsPairing, .credentialLocked, .incompatible: true + default: false + } + } +} diff --git a/Sources/T3Notch/NotchViews.swift b/Sources/T3Notch/NotchViews.swift index b6fbe74..4f2c925 100644 --- a/Sources/T3Notch/NotchViews.swift +++ b/Sources/T3Notch/NotchViews.swift @@ -167,6 +167,14 @@ private struct ExpandedBody: View { if store.activeThreads.count > 1 { ThreadCardDeck(store: store) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .move(edge: .top)), + removal: .opacity.combined( + with: .scale(scale: 0.96, anchor: .top) + ) + ) + ) Divider().overlay(Color.white.opacity(0.08)) } @@ -197,6 +205,10 @@ private struct ExpandedBody: View { } } .padding(.top, 10) + .animation( + .spring(response: 0.34, dampingFraction: 0.86), + value: store.activeThreads.map(\.id) + ) .animation(.spring(response: 0.45, dampingFraction: 0.8), value: store.celebration) .animation(.spring(response: 0.4, dampingFraction: 0.82), value: store.walkthrough) .overlay { @@ -216,35 +228,72 @@ private struct ExpandedBody: View { private struct ThreadCardDeck: View { @Bindable var store: AgentStore - private var groups: [(project: ProjectShell, threads: [ThreadShell])] { - store.activeThreadsByProject + private var machines: [AgentStore.MachineThreadGroup] { + store.activeThreadsByMachine } var body: some View { // No heading and no count: the strip above already says how many are // running, and the project names label the cards well enough. VStack(alignment: .leading, spacing: 6) { - ForEach(groups, id: \.project.id) { group in + ForEach(machines) { machine in VStack(alignment: .leading, spacing: 5) { - if groups.count > 1 { - Text(group.project.title.uppercased()) - .font(.system(size: 8, weight: .bold, design: .rounded)) - .foregroundStyle(.white.opacity(0.35)) - .tracking(0.5) - .lineLimit(1) + if machines.count > 1 || machine.source != .local { + HStack(spacing: 4) { + Image(systemName: machine.source == .local ? "laptopcomputer" : "server.rack") + .font(.system(size: 8, weight: .semibold)) + Text(machine.label.uppercased()) + .font(.system(size: 8, weight: .semibold, design: .rounded)) + .tracking(0.5) + } + .foregroundStyle(.white.opacity(0.42)) + .lineLimit(1) + .accessibilityLabel("Machine \(machine.label)") } - // Wrapped rows instead of a scroller: every card stays - // visible and clickable without a scroll gesture. - ForEach(Array(rows(of: group.threads).enumerated()), id: \.offset) { row in - HStack(alignment: .top, spacing: NotchGeometry.cardSpacing) { - ForEach(row.element) { thread in - ThreadCard( - store: store, - thread: thread, - isSelected: thread.id == store.focusedThread?.id - ) + ForEach(machine.projects, id: \.project.id) { group in + VStack(alignment: .leading, spacing: 5) { + if machine.projects.count > 1 { + Text(group.project.title.uppercased()) + .font(.system(size: 8, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.35)) + .tracking(0.5) + .lineLimit(1) + } + Grid( + alignment: .topLeading, + horizontalSpacing: NotchGeometry.cardSpacing, + verticalSpacing: 5 + ) { + ForEach( + Array(threadRows(group.threads).enumerated()), + id: \.offset + ) { _, row in + GridRow { + ForEach(row) { thread in + ThreadCard( + store: store, + thread: thread, + isSelected: thread.id + == store.focusedThread?.id + ) + .transition( + .asymmetric( + insertion: .opacity.combined( + with: .scale(scale: 0.96) + ), + removal: .opacity.combined( + with: .scale(scale: 0.9) + ) + ) + ) + } + } + } } - Spacer(minLength: 0) + .animation( + .spring(response: 0.32, dampingFraction: 0.84), + value: group.threads.map(\.id) + ) } } } @@ -252,9 +301,9 @@ private struct ThreadCardDeck: View { } } - private func rows(of threads: [ThreadShell]) -> [[ThreadShell]] { - stride(from: 0, to: threads.count, by: NotchGeometry.maxCardsPerRow).map { start in - Array(threads[start.. [[ThreadShell]] { + stride(from: 0, to: threads.count, by: NotchGeometry.maxCardsPerRow).map { + Array(threads[$0.. Color { diff --git a/Sources/T3Notch/SettingsView.swift b/Sources/T3Notch/SettingsView.swift index 9c660ae..86c2eee 100644 --- a/Sources/T3Notch/SettingsView.swift +++ b/Sources/T3Notch/SettingsView.swift @@ -186,34 +186,7 @@ struct SettingsView: View { } } - SettingsCard("Connection") { - SettingsRow( - connectionTitle, - detail: store.endpoint.baseURL.absoluteString - ) { - PillButton("Reconnect") { - store.bootstrap() - } - } - SettingsDivider() - SettingsDivider() - SettingsRow( - "Quick start", - detail: "The first-launch walkthrough, with a connection test." - ) { - PillButton("Show", action: onShowQuickStart) - } - SettingsDivider() - SettingsRow( - "Environment", - detail: [store.machineLabel, store.serverVersionLabel] - .compactMap { $0 } - .joined(separator: " · ") - .nilIfBlank ?? "Not connected yet" - ) { - EmptyView() - } - } + MachineSettingsCard(store: store, onShowQuickStart: onShowQuickStart) HStack { Spacer() @@ -253,15 +226,6 @@ struct SettingsView: View { return T3CodeApp.isRunning ? base : base + " Not running right now, so the browser is used." } - private var connectionTitle: String { - switch store.connectionState { - case .connected: "Connected" - case .connecting: "Connecting…" - case .unauthorized: "Not authorised" - case .disconnected: "Disconnected" - } - } - private var header: some View { HStack(spacing: 11) { NotchShape(topRadius: 5, bottomRadius: 9) @@ -348,7 +312,7 @@ private struct UpdateControls: View { } /// A titled group of rows on a rounded card. -private struct SettingsCard: View { +struct SettingsCard: View { let title: String @ViewBuilder let content: Content @@ -381,7 +345,7 @@ private struct SettingsCard: View { } } -private struct SettingsDivider: View { +struct SettingsDivider: View { var body: some View { Rectangle() .fill(.white.opacity(0.06)) @@ -391,7 +355,7 @@ private struct SettingsDivider: View { } /// Label, explanation, and whatever control the row needs on the right. -private struct SettingsRow: View { +struct SettingsRow: View { let title: String let detail: String? var detailIsProblem = false @@ -433,7 +397,7 @@ private struct SettingsRow: View { } } -private struct SettingsToggle: View { +struct SettingsToggle: View { let title: String let detail: String? @Binding var isOn: Bool @@ -462,7 +426,7 @@ private struct SettingsToggle: View { /// The panel's own switch: a stock macOS one would fight the dark rounded cards, /// and this dims itself when the row is disabled. -private struct NotchToggleStyle: ToggleStyle { +struct NotchToggleStyle: ToggleStyle { @Environment(\.isEnabled) private var isEnabled private static let on = Color(red: 0.21, green: 0.44, blue: 0.98) @@ -547,7 +511,7 @@ private struct SettingsStepperRow: View { } } -private struct PillButton: View { +struct PillButton: View { let title: String let action: () -> Void diff --git a/Sources/T3NotchCore/DPoP.swift b/Sources/T3NotchCore/DPoP.swift new file mode 100644 index 0000000..98ef3fe --- /dev/null +++ b/Sources/T3NotchCore/DPoP.swift @@ -0,0 +1,137 @@ +import CryptoKit +import Foundation + +public enum DPoPError: Error, LocalizedError, Sendable { + case invalidURL + case invalidPrivateKey + case invalidPublicKey + case encodingFailed + + public var errorDescription: String? { + switch self { + case .invalidURL: "The DPoP target URL is invalid." + case .invalidPrivateKey: "The DPoP private key is invalid." + case .invalidPublicKey: "The DPoP public key is invalid." + case .encodingFailed: "Could not encode the DPoP proof." + } + } +} + +public struct DPoPPublicJWK: Codable, Equatable, Sendable { + public let kty: String + public let crv: String + public let x: String + public let y: String + + public init(kty: String = "EC", crv: String = "P-256", x: String, y: String) { + self.kty = kty + self.crv = crv + self.x = x + self.y = y + } +} + +public actor DPoPSigner { + private let key: P256.Signing.PrivateKey + public let privateKeyRawRepresentation: Data + + public init(privateKeyRawRepresentation: Data? = nil) throws { + if let privateKeyRawRepresentation { + do { + key = try P256.Signing.PrivateKey(rawRepresentation: privateKeyRawRepresentation) + } catch { + throw DPoPError.invalidPrivateKey + } + } else { + key = P256.Signing.PrivateKey() + } + self.privateKeyRawRepresentation = key.rawRepresentation + } + + public func publicJWK() throws -> DPoPPublicJWK { + let raw = key.publicKey.x963Representation + guard raw.count == 65, raw.first == 4 else { + throw DPoPError.invalidPublicKey + } + return DPoPPublicJWK( + x: Data(raw[1..<33]).base64URLEncodedString(), + y: Data(raw[33..<65]).base64URLEncodedString() + ) + } + + public func thumbprint() throws -> String { + let jwk = try publicJWK() + let canonical = #"{"crv":"P-256","kty":"EC","x":"\#(jwk.x)","y":"\#(jwk.y)"}"# + return Data(SHA256.hash(data: Data(canonical.utf8))).base64URLEncodedString() + } + + public func createProof( + method: String, + url: URL, + accessToken: String? = nil, + now: Date = .now, + jti: UUID = UUID() + ) throws -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased() + else { + throw DPoPError.invalidURL + } + components.scheme = scheme + components.host = host + if (scheme == "https" && components.port == 443) + || (scheme == "http" && components.port == 80) + { + components.port = nil + } + components.query = nil + components.fragment = nil + guard let normalizedURL = components.url?.absoluteString else { + throw DPoPError.invalidURL + } + + let header = DPoPHeader(jwk: try publicJWK()) + var payload = DPoPPayload( + htm: method.uppercased(), + htu: normalizedURL, + jti: jti.uuidString.lowercased(), + iat: Int(now.timeIntervalSince1970), + ath: nil + ) + if let accessToken { + payload.ath = Data(SHA256.hash(data: Data(accessToken.utf8))).base64URLEncodedString() + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let encodedHeader = try encoder.encode(header).base64URLEncodedString() + let encodedPayload = try encoder.encode(payload).base64URLEncodedString() + let signingInput = Data("\(encodedHeader).\(encodedPayload)".utf8) + let signature = try key.signature(for: signingInput).rawRepresentation + guard signature.count == 64 else { throw DPoPError.encodingFailed } + return "\(encodedHeader).\(encodedPayload).\(signature.base64URLEncodedString())" + } +} + +private struct DPoPHeader: Encodable { + let typ = "dpop+jwt" + let alg = "ES256" + let jwk: DPoPPublicJWK +} + +private struct DPoPPayload: Encodable { + let htm: String + let htu: String + let jti: String + let iat: Int + var ath: String? +} + +extension Data { + func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Sources/T3NotchCore/ElectronSafeStorageImporter.swift b/Sources/T3NotchCore/ElectronSafeStorageImporter.swift new file mode 100644 index 0000000..0aa324e --- /dev/null +++ b/Sources/T3NotchCore/ElectronSafeStorageImporter.swift @@ -0,0 +1,235 @@ +import CCommonCrypto +import CryptoKit +import Foundation +import LocalAuthentication +import Security + +public enum T3ConnectSessionDetection: Equatable, Sendable { + case unavailable + case signedOut + case signedIn(ciphertextFingerprint: String) + case unsafePermissions + case incompatible(String) +} + +public struct ImportedElectronSession: Sendable, Equatable { + public let clientJWT: String + public let ciphertextFingerprint: String + + public init(clientJWT: String, ciphertextFingerprint: String) { + self.clientJWT = clientJWT + self.ciphertextFingerprint = ciphertextFingerprint + } +} + +public enum ElectronSafeStorageError: Error, LocalizedError, Sendable { + case fileUnsafe + case unsafePermissions + case formatUnsupported + case keychainDenied + case decryptionFailed + case invalidSession + + public var errorDescription: String? { + switch self { + case .fileUnsafe: "T3 Code's session file did not pass local security checks." + case .unsafePermissions: + "T3 Code's session file is writable by other local users." + case .formatUnsupported: + "This T3 Code sign-in format is not supported by this T3Notch version." + case .keychainDenied: "T3 Code's Keychain encryption key was not made available." + case .decryptionFailed: "The T3 Code session could not be decrypted." + case .invalidSession: "T3 Code's saved sign-in is invalid or expired." + } + } +} + +public struct ElectronSafeStorageImporter: Sendable { + public static let tokenKey = "__clerk_client_jwt" + public static let keychainService = "t3code Safe Storage" + public static let keychainAccount = "t3code Key" + + public let tokenFile: URL + + public init( + tokenFile: URL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".t3/userdata/clerk-tokens.json") + ) { + self.tokenFile = tokenFile + } + + /// Performs only filesystem/schema checks. It never reads T3 Code's + /// Keychain item, so settings can safely call this on launch and activation. + public func detect() -> T3ConnectSessionDetection { + guard FileManager.default.fileExists(atPath: tokenFile.path) else { + return .unavailable + } + do { + let encrypted = try readEncryptedRecord() + guard encrypted.hasPrefix("enc:"), + let bytes = Data(base64Encoded: String(encrypted.dropFirst(4))), + bytes.starts(with: Data("v10".utf8)) + else { + throw ElectronSafeStorageError.formatUnsupported + } + let fingerprint = Self.sha256(encrypted) + return .signedIn(ciphertextFingerprint: fingerprint) + } catch ElectronSafeStorageError.unsafePermissions { + return .unsafePermissions + } catch ElectronSafeStorageError.invalidSession { + return .signedOut + } catch { + return .incompatible(error.localizedDescription) + } + } + + /// This is intentionally the only API that may prompt for T3 Code's + /// Safe Storage Keychain item. + public func importSession() throws -> ImportedElectronSession { + let encrypted = try readEncryptedRecord() + guard encrypted.hasPrefix("enc:"), + let bytes = Data(base64Encoded: String(encrypted.dropFirst(4))), + bytes.starts(with: Data("v10".utf8)) + else { + throw ElectronSafeStorageError.formatUnsupported + } + let password = try readSafeStoragePassword() + let clientJWT = try Self.decryptV10(Data(bytes.dropFirst(3)), password: password) + guard Self.isJWT(clientJWT) else { + throw ElectronSafeStorageError.invalidSession + } + return ImportedElectronSession( + clientJWT: clientJWT, + ciphertextFingerprint: Self.sha256(encrypted) + ) + } + + private func readEncryptedRecord() throws -> String { + let values = try tokenFile.resourceValues(forKeys: [ + .isRegularFileKey, + .isSymbolicLinkKey, + ]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { + throw ElectronSafeStorageError.fileUnsafe + } + let attributes = try FileManager.default.attributesOfItem(atPath: tokenFile.path) + let owner = (attributes[.ownerAccountID] as? NSNumber)?.uint32Value + let permissions = (attributes[.posixPermissions] as? NSNumber)?.uint16Value + guard owner == getuid(), let permissions else { + throw ElectronSafeStorageError.fileUnsafe + } + guard permissions & 0o022 == 0 else { + throw ElectronSafeStorageError.unsafePermissions + } + let data = try Data(contentsOf: tokenFile, options: [.mappedIfSafe]) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw ElectronSafeStorageError.formatUnsupported + } + guard let value = object[Self.tokenKey] as? String else { + throw ElectronSafeStorageError.invalidSession + } + guard !value.isEmpty else { throw ElectronSafeStorageError.invalidSession } + return value + } + + private func readSafeStoragePassword() throws -> Data { + let context = LAContext() + context.localizedReason = "Import your T3 Connect sign-in into T3Notch." + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.keychainService, + kSecAttrAccount as String: Self.keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data, !data.isEmpty else { + throw ElectronSafeStorageError.keychainDenied + } + return data + } + + static func decryptV10(_ ciphertext: Data, password: Data) throws -> String { + var key = Data(count: kCCKeySizeAES128) + let salt = Data("saltysalt".utf8) + let derivationStatus = key.withUnsafeMutableBytes { keyBytes in + password.withUnsafeBytes { passwordBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordBytes.bindMemory(to: Int8.self).baseAddress, + password.count, + saltBytes.bindMemory(to: UInt8.self).baseAddress, + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + 1003, + keyBytes.bindMemory(to: UInt8.self).baseAddress, + kCCKeySizeAES128 + ) + } + } + } + guard derivationStatus == kCCSuccess else { + throw ElectronSafeStorageError.decryptionFailed + } + + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + let outputCapacity = ciphertext.count + kCCBlockSizeAES128 + var output = Data(count: outputCapacity) + var moved = 0 + let cryptStatus = output.withUnsafeMutableBytes { outputBytes in + ciphertext.withUnsafeBytes { inputBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + inputBytes.baseAddress, + ciphertext.count, + outputBytes.baseAddress, + outputCapacity, + &moved + ) + } + } + } + } + key.resetBytes(in: 0.. Bool { + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3 else { return false } + for part in parts.prefix(2) { + var encoded = String(part) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4) + guard let data = Data(base64Encoded: encoded), + (try? JSONSerialization.jsonObject(with: data)) != nil + else { + return false + } + } + return true + } + + private static func sha256(_ value: String) -> String { + Data(SHA256.hash(data: Data(value.utf8))).base64URLEncodedString() + } +} diff --git a/Sources/T3NotchCore/EnvironmentProfileStore.swift b/Sources/T3NotchCore/EnvironmentProfileStore.swift new file mode 100644 index 0000000..fd5f2e9 --- /dev/null +++ b/Sources/T3NotchCore/EnvironmentProfileStore.swift @@ -0,0 +1,65 @@ +import Foundation + +public final class EnvironmentProfileStore: @unchecked Sendable { + private struct Document: Codable { + var version = 1 + var profiles: [EnvironmentProfile] + } + + private let defaults: UserDefaults + private let key: String + private let lock = NSLock() + + public init( + defaults: UserDefaults = .standard, + key: String = "gg.t3tools.t3notch.environmentProfiles.v1" + ) { + self.defaults = defaults + self.key = key + } + + public func load() -> [EnvironmentProfile] { + lock.withLock { loadUnlocked() } + } + + public func save(_ profiles: [EnvironmentProfile]) throws { + try lock.withLock { try saveUnlocked(profiles) } + } + + public func upsert(_ profile: EnvironmentProfile) throws { + try lock.withLock { + var profiles = loadUnlocked() + if let index = profiles.firstIndex(where: { + $0.environmentID == profile.environmentID + }) { + profiles[index] = profile + } else { + profiles.append(profile) + } + try saveUnlocked(profiles) + } + } + + public func remove(_ environmentID: EnvironmentID) throws { + try lock.withLock { + try saveUnlocked( + loadUnlocked().filter { $0.environmentID != environmentID } + ) + } + } + + private func loadUnlocked() -> [EnvironmentProfile] { + guard let data = defaults.data(forKey: key), + let document = try? JSONDecoder().decode(Document.self, from: data), + document.version == 1 + else { + return [] + } + return document.profiles + } + + private func saveUnlocked(_ profiles: [EnvironmentProfile]) throws { + let data = try JSONEncoder().encode(Document(profiles: profiles)) + defaults.set(data, forKey: key) + } +} diff --git a/Sources/T3NotchCore/FormURLEncoding.swift b/Sources/T3NotchCore/FormURLEncoding.swift new file mode 100644 index 0000000..c7f7075 --- /dev/null +++ b/Sources/T3NotchCore/FormURLEncoding.swift @@ -0,0 +1,39 @@ +import Foundation + +enum FormURLEncoding { + private static let hexadecimal = Array("0123456789ABCDEF".utf8) + + static func data(_ fields: [(String, String)]) -> Data { + let body = fields.map { field in + "\(encode(field.0))=\(encode(field.1))" + }.joined(separator: "&") + return Data(body.utf8) + } + + private static func encode(_ value: String) -> String { + var encoded = "" + encoded.reserveCapacity(value.utf8.count) + for byte in value.utf8 { + if byte == 0x20 { + encoded.append("+") + } else if isFormUnescaped(byte) { + encoded.unicodeScalars.append(UnicodeScalar(byte)) + } else { + encoded.append("%") + encoded.unicodeScalars.append(UnicodeScalar(hexadecimal[Int(byte >> 4)])) + encoded.unicodeScalars.append(UnicodeScalar(hexadecimal[Int(byte & 0x0F)])) + } + } + return encoded + } + + private static func isFormUnescaped(_ byte: UInt8) -> Bool { + (byte >= 65 && byte <= 90) + || (byte >= 97 && byte <= 122) + || (byte >= 48 && byte <= 57) + || byte == 42 + || byte == 45 + || byte == 46 + || byte == 95 + } +} diff --git a/Sources/T3NotchCore/MultiEnvironmentCoordinator.swift b/Sources/T3NotchCore/MultiEnvironmentCoordinator.swift new file mode 100644 index 0000000..ecb4e25 --- /dev/null +++ b/Sources/T3NotchCore/MultiEnvironmentCoordinator.swift @@ -0,0 +1,303 @@ +import Foundation +import os + +public final class MultiEnvironmentCoordinator: @unchecked Sendable { + private final class Session: @unchecked Sendable { + private struct Data { + var profile: EnvironmentProfile + var descriptor: EnvironmentDescriptor? + var state: EnvironmentConnectionState = .connecting + var shell: ShellSnapshot? + var shellTask: Task? + } + + private let data: OSAllocatedUnfairLock + let transport: PollingTransport + + init( + profile: EnvironmentProfile, + descriptor: EnvironmentDescriptor?, + transport: PollingTransport + ) { + data = OSAllocatedUnfairLock(initialState: Data( + profile: profile, + descriptor: descriptor + )) + self.transport = transport + } + + var source: EnvironmentSource { + data.withLock { $0.profile.source } + } + + func updateProfile(_ profile: EnvironmentProfile) { + data.withLock { $0.profile = profile } + } + + func updateState(_ state: EnvironmentConnectionState) { + data.withLock { $0.state = state } + } + + func updateShell(_ shell: ShellSnapshot) { + data.withLock { + $0.shell = shell + $0.state = .connected + } + } + + func installShellTask(_ task: Task) { + let previous = data.withLock { data -> Task? in + let previous = data.shellTask + data.shellTask = task + return previous + } + previous?.cancel() + } + + func cancelShellTask() { + data.withLock { data -> Task? in + defer { data.shellTask = nil } + return data.shellTask + }?.cancel() + } + + func snapshot() -> EnvironmentSnapshot { + data.withLock { + EnvironmentSnapshot( + profile: $0.profile, + descriptor: $0.descriptor, + connectionState: $0.state, + activeAccessPath: $0.profile.source, + shell: $0.shell + ) + } + } + } + + private struct State { + var sessions: [EnvironmentID: Session] = [:] + var focused: ScopedThreadID? + var expanded = false + var detailTask: Task? + var detailGeneration = 0 + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + private let continuation: AsyncStream.Continuation + public let events: AsyncStream + + public init() { + let pair = AsyncStream.makeStream() + events = pair.stream + continuation = pair.continuation + } + + deinit { + stop() + } + + public func register( + profile: EnvironmentProfile, + descriptor: EnvironmentDescriptor?, + endpoint: ServerEndpoint, + authorizer: any HTTPAuthorizer + ) { + let client = T3HTTPClient(endpoint: endpoint, authorizer: authorizer) + let transport = PollingTransport( + client: client, + configuration: profile.source == .local ? PollingConfiguration() : .remote + ) + let session = Session( + profile: profile, + descriptor: descriptor, + transport: transport + ) + transport.onConnectionStateChange = { [weak self, weak session] state in + guard let self, let session else { return } + let environmentState: EnvironmentConnectionState = switch state { + case .connecting: .connecting + case .connected: .connected + case .disconnected: .offline(nil) + case .unauthorized: + session.source == .direct ? .needsPairing : .unauthorized + } + session.updateState(environmentState) + self.emit(session) + } + transport.onRepeatedFailure = { [weak self, weak session] in + guard let self, let session else { return } + self.emit(session) + } + let (previous, focused) = state.withLock { + state -> (Session?, ScopedThreadID?) in + let previous = state.sessions.updateValue(session, forKey: profile.environmentID) + session.transport.setExpanded(state.expanded) + if state.focused?.environmentID == profile.environmentID { + session.transport.setFocusedThread(state.focused?.threadID) + } + return (previous, state.focused) + } + previous?.transport.stop() + previous?.cancelShellTask() + emit(session) + let shellTask = Task { [weak self, weak session] in + for await shell in transport.shell { + guard let self, let session else { return } + session.updateShell(shell) + self.emit(session) + } + } + session.installShellTask(shellTask) + if focused?.environmentID == profile.environmentID { + setFocusedThread(focused) + } + } + + public func snapshots() -> [EnvironmentSnapshot] { + state.withLock { state in + state.sessions.values.map(makeSnapshot).sorted { + sourcePriority($0.activeAccessPath) < sourcePriority($1.activeAccessPath) + } + } + } + + public func setFocusedThread(_ focused: ScopedThreadID?) { + let detailSource: (PollingTransport, ScopedThreadID, Int)? = state.withLock { state in + state.detailTask?.cancel() + state.detailTask = nil + state.detailGeneration &+= 1 + state.focused = focused + for (environmentID, session) in state.sessions { + session.transport.setFocusedThread( + environmentID == focused?.environmentID ? focused?.threadID : nil + ) + } + guard let focused, let session = state.sessions[focused.environmentID] else { + return nil + } + return (session.transport, focused, state.detailGeneration) + } + guard let (transport, focused, generation) = detailSource else { return } + let task = Task { [weak self] in + for await detail in transport.threadDetail(focused.threadID) { + self?.continuation.yield(.detail(focused, detail)) + } + } + let accepted = state.withLock { state -> Bool in + guard state.focused == focused, + state.detailGeneration == generation + else { + return false + } + state.detailTask = task + return true + } + if !accepted { + task.cancel() + } + } + + public func setExpanded(_ expanded: Bool) { + state.withLock { state in + state.expanded = expanded + for session in state.sessions.values { + session.transport.setExpanded(expanded) + } + } + } + + public func reconnect(_ environmentID: EnvironmentID? = nil) { + let sessions = state.withLock { state -> [Session] in + var changed: [Session] = [] + for (id, session) in state.sessions where environmentID == nil || environmentID == id { + session.updateState(.connecting) + session.transport.requestImmediatePoll() + changed.append(session) + } + return changed + } + for session in sessions { + emit(session) + } + } + + public func dispatch(_ command: DispatchCommand, to environmentID: EnvironmentID) async throws { + let transport = state.withLock { $0.sessions[environmentID]?.transport } + guard let transport else { + throw T3HTTPError.transport( + NSError( + domain: "T3Notch.MultiEnvironmentCoordinator", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "The machine is not connected."] + ) + ) + } + try await transport.dispatch(command) + } + + public func updateProfile(_ profile: EnvironmentProfile) { + let session = state.withLock { state -> Session? in + guard let session = state.sessions[profile.environmentID] else { return nil } + session.updateProfile(profile) + return session + } + if let session { emit(session) } + } + + public func remove(_ environmentID: EnvironmentID) { + remove(environmentID, emitEvent: true) + } + + /// Stops polling while a profile remains in the presentation layer (for + /// example, a disabled or credential-locked machine in Settings). + public func suspend(_ environmentID: EnvironmentID) { + remove(environmentID, emitEvent: false) + } + + public func stop() { + let sessions = state.withLock { state -> [Session] in + state.detailTask?.cancel() + state.detailTask = nil + let values = Array(state.sessions.values) + state.sessions = [:] + return values + } + for session in sessions { + session.cancelShellTask() + session.transport.stop() + } + continuation.finish() + } + + private func remove(_ environmentID: EnvironmentID, emitEvent: Bool) { + let removed = state.withLock { state -> Session? in + if state.focused?.environmentID == environmentID { + state.focused = nil + state.detailTask?.cancel() + state.detailTask = nil + } + return state.sessions.removeValue(forKey: environmentID) + } + removed?.cancelShellTask() + removed?.transport.stop() + if emitEvent, removed != nil { + continuation.yield(.removed(environmentID)) + } + } + + private func emit(_ session: Session) { + continuation.yield(.snapshot(makeSnapshot(session))) + } + + private func makeSnapshot(_ session: Session) -> EnvironmentSnapshot { + session.snapshot() + } + + private func sourcePriority(_ source: EnvironmentSource) -> Int { + switch source { + case .local: 0 + case .direct: 1 + case .t3Connect: 2 + } + } +} diff --git a/Sources/T3NotchCore/RemoteCredentialVault.swift b/Sources/T3NotchCore/RemoteCredentialVault.swift new file mode 100644 index 0000000..487722e --- /dev/null +++ b/Sources/T3NotchCore/RemoteCredentialVault.swift @@ -0,0 +1,218 @@ +import Foundation +import LocalAuthentication +import Security + +public struct RemoteAccessCredential: Codable, Sendable, Equatable { + public var accessToken: String + public var expiresAt: Date + public var source: EnvironmentSource + + public init(accessToken: String, expiresAt: Date, source: EnvironmentSource) { + self.accessToken = accessToken + self.expiresAt = expiresAt + self.source = source + } + + public var needsRefresh: Bool { + expiresAt.timeIntervalSinceNow <= 300 + } +} + +public struct ImportedT3ConnectCredential: Codable, Sendable, Equatable { + public var clerkClientJWT: String + public var ciphertextFingerprint: String + + public init(clerkClientJWT: String, ciphertextFingerprint: String) { + self.clerkClientJWT = clerkClientJWT + self.ciphertextFingerprint = ciphertextFingerprint + } +} + +public struct RemoteCredentialDocument: Codable, Sendable, Equatable { + public var version = 1 + /// Direct-pairing grants are intentionally kept separate from relay-minted + /// grants. A logical environment may have both access paths, and falling + /// back to Connect must never destroy the credential needed to switch back. + public var environmentCredentials: [String: RemoteAccessCredential] = [:] + public var connectEnvironmentCredentials: [String: RemoteAccessCredential] = [:] + public var relayAccessTokens: [String: RemoteAccessCredential] = [:] + public var importedT3Connect: ImportedT3ConnectCredential? + public var dpopPrivateKey: Data? + + public init() {} + + private enum CodingKeys: String, CodingKey { + case version + case environmentCredentials + case connectEnvironmentCredentials + case relayAccessTokens + case importedT3Connect + case dpopPrivateKey + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + environmentCredentials = try container.decodeIfPresent( + [String: RemoteAccessCredential].self, + forKey: .environmentCredentials + ) ?? [:] + connectEnvironmentCredentials = try container.decodeIfPresent( + [String: RemoteAccessCredential].self, + forKey: .connectEnvironmentCredentials + ) ?? [:] + relayAccessTokens = try container.decodeIfPresent( + [String: RemoteAccessCredential].self, + forKey: .relayAccessTokens + ) ?? [:] + importedT3Connect = try container.decodeIfPresent( + ImportedT3ConnectCredential.self, + forKey: .importedT3Connect + ) + dpopPrivateKey = try container.decodeIfPresent(Data.self, forKey: .dpopPrivateKey) + } +} + +public enum RemoteCredentialVaultError: Error, LocalizedError, Sendable { + case locked + case invalidDocument + case unexpectedStatus(OSStatus) + + public var errorDescription: String? { + switch self { + case .locked: "Remote credentials are locked. Unlock them in Settings." + case .invalidDocument: "The remote credential vault is damaged." + case let .unexpectedStatus(status): "Keychain error: \(status)" + } + } +} + +public protocol RemoteCredentialStoring: Sendable { + func document() throws -> RemoteCredentialDocument + func update( + _ transform: (inout RemoteCredentialDocument) throws -> Void + ) throws + func forgetT3Connect() throws +} + +public final class RemoteCredentialVault: RemoteCredentialStoring, @unchecked Sendable { + public static let service = "gg.t3tools.t3notch" + public static let account = "remote-credential-vault-v1" + + private let lock = NSLock() + private let mutationLock = NSLock() + private var cached: RemoteCredentialDocument? + + public init() {} + + public func loadWithoutPrompt() throws -> RemoteCredentialDocument { + try load(allowsInteraction: false) + } + + public func unlock() throws -> RemoteCredentialDocument { + try load(allowsInteraction: true) + } + + public func document() throws -> RemoteCredentialDocument { + if let cached = lock.withLock({ self.cached }) { + return cached + } + return try loadWithoutPrompt() + } + + public func update( + _ transform: (inout RemoteCredentialDocument) throws -> Void + ) throws { + try mutationLock.withLock { + var document = try self.document() + try transform(&document) + try saveUnlocked(document) + } + } + + public func save(_ document: RemoteCredentialDocument) throws { + try mutationLock.withLock { + try saveUnlocked(document) + } + } + + private func saveUnlocked(_ document: RemoteCredentialDocument) throws { + let data = try JSONEncoder().encode(document) + let query = baseQuery + var attributes = query + attributes[kSecValueData as String] = data + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + let status = SecItemAdd(attributes as CFDictionary, nil) + if status == errSecDuplicateItem { + let updateStatus = SecItemUpdate( + query as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + guard updateStatus == errSecSuccess else { + throw RemoteCredentialVaultError.unexpectedStatus(updateStatus) + } + } else if status != errSecSuccess { + throw RemoteCredentialVaultError.unexpectedStatus(status) + } + lock.withLock { cached = document } + } + + public func removeEnvironment(_ environmentID: EnvironmentID) throws { + try update { document in + document.environmentCredentials.removeValue(forKey: environmentID.rawValue) + document.connectEnvironmentCredentials.removeValue(forKey: environmentID.rawValue) + } + } + + public func forgetT3Connect() throws { + try update { document in + document.importedT3Connect = nil + document.relayAccessTokens = [:] + document.connectEnvironmentCredentials = [:] + } + } + + private var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + ] + } + + private func load(allowsInteraction: Bool) throws -> RemoteCredentialDocument { + if let cached = lock.withLock({ self.cached }) { + return cached + } + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + let context = LAContext() + context.interactionNotAllowed = !allowsInteraction + if allowsInteraction { + context.localizedReason = "Unlock T3Notch remote machine credentials." + } + query[kSecUseAuthenticationContext as String] = context + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + let empty = RemoteCredentialDocument() + lock.withLock { cached = empty } + return empty + } + if status == errSecInteractionNotAllowed || status == errSecAuthFailed { + throw RemoteCredentialVaultError.locked + } + guard status == errSecSuccess, let data = item as? Data else { + throw RemoteCredentialVaultError.unexpectedStatus(status) + } + guard let document = try? JSONDecoder().decode(RemoteCredentialDocument.self, from: data), + document.version == 1 + else { + throw RemoteCredentialVaultError.invalidDocument + } + lock.withLock { cached = document } + return document + } +} diff --git a/Sources/T3NotchCore/RemoteModels.swift b/Sources/T3NotchCore/RemoteModels.swift new file mode 100644 index 0000000..499d70a --- /dev/null +++ b/Sources/T3NotchCore/RemoteModels.swift @@ -0,0 +1,128 @@ +import Foundation + +public struct EnvironmentID: RawRepresentable, Hashable, Codable, Sendable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public var description: String { rawValue } +} + +public struct ScopedThreadID: Hashable, Codable, Sendable { + public let environmentID: EnvironmentID + public let threadID: String + + public init(environmentID: EnvironmentID, threadID: String) { + self.environmentID = environmentID + self.threadID = threadID + } + + public var storageKey: String { + Data(environmentID.rawValue.utf8).base64URLEncodedString() + + "." + + Data(threadID.utf8).base64URLEncodedString() + } +} + +public struct ScopedProjectID: Hashable, Codable, Sendable { + public let environmentID: EnvironmentID + public let projectID: String + + public init(environmentID: EnvironmentID, projectID: String) { + self.environmentID = environmentID + self.projectID = projectID + } + + public var storageKey: String { + Data(environmentID.rawValue.utf8).base64URLEncodedString() + + "." + + Data(projectID.utf8).base64URLEncodedString() + } +} + +public struct ScopedRequestID: Hashable, Sendable { + public let thread: ScopedThreadID + public let requestID: String + + public init(thread: ScopedThreadID, requestID: String) { + self.thread = thread + self.requestID = requestID + } +} + +public enum EnvironmentSource: String, Codable, Sendable, CaseIterable { + case local + case direct + case t3Connect +} + +public struct EnvironmentProfile: Identifiable, Codable, Sendable, Equatable { + public let environmentID: EnvironmentID + public var label: String + public var directEndpoint: ServerEndpoint? + public var source: EnvironmentSource + public var enabled: Bool + public var allowsInsecureHTTP: Bool + + public var id: EnvironmentID { environmentID } + + public init( + environmentID: EnvironmentID, + label: String, + directEndpoint: ServerEndpoint? = nil, + source: EnvironmentSource, + enabled: Bool = true, + allowsInsecureHTTP: Bool = false + ) { + self.environmentID = environmentID + self.label = label + self.directEndpoint = directEndpoint + self.source = source + self.enabled = enabled + self.allowsInsecureHTTP = allowsInsecureHTTP + } +} + +public enum EnvironmentConnectionState: Equatable, Sendable { + case connecting + case connected + case offline(String?) + case unauthorized + case needsPairing + case credentialLocked + case incompatible(String) +} + +public struct EnvironmentSnapshot: Sendable { + public let profile: EnvironmentProfile + public let descriptor: EnvironmentDescriptor? + public let connectionState: EnvironmentConnectionState + public let activeAccessPath: EnvironmentSource + public let shell: ShellSnapshot? + + public init( + profile: EnvironmentProfile, + descriptor: EnvironmentDescriptor?, + connectionState: EnvironmentConnectionState, + activeAccessPath: EnvironmentSource, + shell: ShellSnapshot? + ) { + self.profile = profile + self.descriptor = descriptor + self.connectionState = connectionState + self.activeAccessPath = activeAccessPath + self.shell = shell + } +} + +public enum EnvironmentEvent: Sendable { + case snapshot(EnvironmentSnapshot) + case detail(ScopedThreadID, ThreadDetailSnapshot) + case removed(EnvironmentID) +} diff --git a/Sources/T3NotchCore/RemotePairing.swift b/Sources/T3NotchCore/RemotePairing.swift new file mode 100644 index 0000000..810daa2 --- /dev/null +++ b/Sources/T3NotchCore/RemotePairing.swift @@ -0,0 +1,279 @@ +import Foundation + +public enum RemotePairingError: Error, LocalizedError, Sendable { + case invalidPairingURL + case missingBackend + case missingCredential + case insecureHTTPNeedsConfirmation + case environmentUnavailable + case environmentIdentityMissing + case environmentMismatch + case tokenRejected + case unexpectedTokenType + case unexpectedScopes + case malformedResponse + + public var errorDescription: String? { + switch self { + case .invalidPairingURL: "The pairing URL is invalid." + case .missingBackend: "Enter a backend URL." + case .missingCredential: "Enter a pairing code." + case .insecureHTTPNeedsConfirmation: + "This machine uses unencrypted HTTP. Confirm the insecure connection to continue." + case .environmentUnavailable: "The remote T3 Code environment could not be reached." + case .environmentIdentityMissing: "The remote environment did not report a stable identity." + case .environmentMismatch: "The remote endpoint changed to a different environment." + case .tokenRejected: "The pairing credential was rejected or expired." + case .unexpectedTokenType: "The environment did not issue a DPoP-bound session." + case .unexpectedScopes: "The environment granted different permissions than requested." + case .malformedResponse: "The remote environment returned an invalid response." + } + } +} + +public struct RemotePairingTarget: Sendable, Equatable { + public let endpoint: ServerEndpoint + public let credential: String + + public init(pairingURL raw: String) throws { + guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)), + ["http", "https", "ws", "wss"].contains(url.scheme?.lowercased() ?? "") + else { + throw RemotePairingError.invalidPairingURL + } + let hash = URLComponents(string: "?\(url.fragment ?? "")")?.queryItems ?? [] + let token = hash.first(where: { $0.name == "token" })?.value + ?? URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? + .first(where: { $0.name == "token" })?.value + guard let credential = token?.trimmingCharacters(in: .whitespacesAndNewlines), + !credential.isEmpty + else { + throw RemotePairingError.missingCredential + } + + let backend: URL + if url.host?.lowercased() == "app.t3.codes", + let hostValue = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? + .first(where: { $0.name == "host" })?.value + { + backend = try Self.normalizedURL(hostValue) + } else { + backend = try Self.normalizedURL(url.absoluteString) + } + self.endpoint = try ServerEndpoint(httpBaseURL: backend) + self.credential = credential + } + + public init(host rawHost: String, pairingCode: String) throws { + let code = pairingCode.trimmingCharacters(in: .whitespacesAndNewlines) + guard !code.isEmpty else { throw RemotePairingError.missingCredential } + self.endpoint = try ServerEndpoint(httpBaseURL: Self.normalizedURL(rawHost)) + self.credential = code + } + + private static func normalizedURL(_ raw: String) throws -> URL { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { throw RemotePairingError.missingBackend } + value = value.replacingOccurrences(of: #"^/+"#, with: "", options: .regularExpression) + if !value.contains("://") { + value = "https://\(value)" + } + guard var components = URLComponents(string: value), + let scheme = components.scheme?.lowercased(), + ["http", "https", "ws", "wss"].contains(scheme), + components.host != nil + else { + throw RemotePairingError.invalidPairingURL + } + components.scheme = switch scheme { + case "ws": "http" + case "wss": "https" + default: scheme + } + components.path = "/" + components.query = nil + components.fragment = nil + guard let url = components.url else { throw RemotePairingError.invalidPairingURL } + return url + } +} + +public struct RemotePairingResult: Sendable { + public let profile: EnvironmentProfile + public let descriptor: EnvironmentDescriptor + public let credential: RemoteAccessCredential + + public init( + profile: EnvironmentProfile, + descriptor: EnvironmentDescriptor, + credential: RemoteAccessCredential + ) { + self.profile = profile + self.descriptor = descriptor + self.credential = credential + } +} + +public actor RemotePairingClient { + public static let scopes = ["orchestration:read", "orchestration:operate"] + + private let session: URLSession + private let signer: DPoPSigner + private let requestObserver: (@Sendable (URLRequest) -> Void)? + + public init(session: URLSession = .shared, signer: DPoPSigner) { + self.session = session + self.signer = signer + self.requestObserver = nil + } + + init( + session: URLSession, + signer: DPoPSigner, + requestObserver: @escaping @Sendable (URLRequest) -> Void + ) { + self.session = session + self.signer = signer + self.requestObserver = requestObserver + } + + public func pair( + target: RemotePairingTarget, + allowsInsecureHTTP: Bool = false, + source: EnvironmentSource = .direct, + expectedEnvironmentID: EnvironmentID? = nil + ) async throws -> RemotePairingResult { + if target.endpoint.httpBaseURL.scheme == "http", + !target.endpoint.isLoopback, + !allowsInsecureHTTP + { + throw RemotePairingError.insecureHTTPNeedsConfirmation + } + + let descriptor = try await fetchDescriptor(endpoint: target.endpoint) + guard let rawEnvironmentID = descriptor.environmentId?.trimmingCharacters( + in: .whitespacesAndNewlines + ), !rawEnvironmentID.isEmpty + else { + throw RemotePairingError.environmentIdentityMissing + } + let environmentID = EnvironmentID(rawEnvironmentID) + if let expectedEnvironmentID, expectedEnvironmentID != environmentID { + throw RemotePairingError.environmentMismatch + } + + let tokenURL = target.endpoint.baseURL.appendingPathComponent("oauth/token") + let proof = try await signer.createProof(method: "POST", url: tokenURL) + var request = URLRequest(url: tokenURL) + request.httpMethod = "POST" + request.timeoutInterval = 10 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(proof, forHTTPHeaderField: "DPoP") + request.httpBody = Self.formEncoded([ + ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), + ("subject_token", target.credential), + ( + "subject_token_type", + "urn:t3:params:oauth:token-type:environment-bootstrap" + ), + ( + "requested_token_type", + "urn:ietf:params:oauth:token-type:access_token" + ), + ("scope", Self.scopes.joined(separator: " ")), + ("client_label", "T3Notch"), + ("client_device_type", "desktop"), + ("client_os", "macOS"), + ]) + + requestObserver?(request) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw RemotePairingError.malformedResponse + } + guard (200..<300).contains(http.statusCode) else { + throw RemotePairingError.tokenRejected + } + let token: TokenResponse + do { + token = try JSONDecoder().decode(TokenResponse.self, from: data) + } catch { + throw RemotePairingError.malformedResponse + } + guard token.tokenType == "DPoP" else { + throw RemotePairingError.unexpectedTokenType + } + guard Set(token.scope.split(separator: " ").map(String.init)) == Set(Self.scopes) else { + throw RemotePairingError.unexpectedScopes + } + + let authorizer = DPoPHTTPAuthorizer(accessToken: token.accessToken, signer: signer) + let client = T3HTTPClient( + endpoint: target.endpoint, + authorizer: authorizer, + session: session + ) + try await client.verifySession() + let verifiedDescriptor = try await client.fetchEnvironment() + guard verifiedDescriptor.environmentId == rawEnvironmentID else { + throw RemotePairingError.environmentMismatch + } + let profile = EnvironmentProfile( + environmentID: environmentID, + label: descriptor.label?.trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty ?? rawEnvironmentID, + directEndpoint: target.endpoint, + source: source, + enabled: true, + allowsInsecureHTTP: allowsInsecureHTTP + ) + return RemotePairingResult( + profile: profile, + descriptor: descriptor, + credential: RemoteAccessCredential( + accessToken: token.accessToken, + expiresAt: Date().addingTimeInterval(token.expiresIn), + source: source + ) + ) + } + + private func fetchDescriptor(endpoint: ServerEndpoint) async throws -> EnvironmentDescriptor { + let url = endpoint.baseURL + .appendingPathComponent(".well-known/t3/environment") + var request = URLRequest(url: url) + request.timeoutInterval = 10 + request.setValue("application/json", forHTTPHeaderField: "Accept") + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) + else { + throw RemotePairingError.environmentUnavailable + } + return try JSONDecoder().decode(EnvironmentDescriptor.self, from: data) + } catch let error as RemotePairingError { + throw error + } catch { + throw RemotePairingError.environmentUnavailable + } + } + + private static func formEncoded(_ fields: [(String, String)]) -> Data { + FormURLEncoding.data(fields) + } +} + +private struct TokenResponse: Decodable { + let accessToken: String + let tokenType: String + let expiresIn: TimeInterval + let scope: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scope + } +} diff --git a/Sources/T3NotchCore/ServerDiscovery.swift b/Sources/T3NotchCore/ServerDiscovery.swift index 836b770..d9c8bbc 100644 --- a/Sources/T3NotchCore/ServerDiscovery.swift +++ b/Sources/T3NotchCore/ServerDiscovery.swift @@ -1,16 +1,90 @@ import Foundation -public struct ServerEndpoint: Sendable, Equatable { - public var host: String - public var port: Int +public enum ServerEndpointError: Error, LocalizedError, Sendable { + case unsupportedScheme + case missingHost + case credentialsNotAllowed + + public var errorDescription: String? { + switch self { + case .unsupportedScheme: "Only HTTP and HTTPS endpoints are supported." + case .missingHost: "The endpoint is missing a host." + case .credentialsNotAllowed: "Endpoint URLs cannot contain credentials." + } + } +} + +public struct ServerEndpoint: Codable, Sendable, Equatable, Hashable { + public let httpBaseURL: URL + + private enum CodingKeys: String, CodingKey { + case httpBaseURL + } + + public init(httpBaseURL: URL) throws { + guard let scheme = httpBaseURL.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { + throw ServerEndpointError.unsupportedScheme + } + guard httpBaseURL.host != nil else { + throw ServerEndpointError.missingHost + } + var components = URLComponents(url: httpBaseURL, resolvingAgainstBaseURL: false) + guard components?.user == nil, components?.password == nil else { + throw ServerEndpointError.credentialsNotAllowed + } + components?.scheme = scheme + components?.path = "/" + components?.query = nil + components?.fragment = nil + guard let canonical = components?.url else { + throw ServerEndpointError.missingHost + } + self.httpBaseURL = canonical + } public init(host: String = "127.0.0.1", port: Int = 3773) { - self.host = host - self.port = port + if host.contains(":") { + let literal = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + self.httpBaseURL = URL(string: "http://[\(literal)]:\(port)/")! + return + } + var components = URLComponents() + components.scheme = "http" + components.host = host + components.port = port + components.path = "/" + self.httpBaseURL = components.url! + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init(httpBaseURL: container.decode(URL.self, forKey: .httpBaseURL)) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(httpBaseURL, forKey: .httpBaseURL) + } + + public var host: String { httpBaseURL.host ?? "" } + public var port: Int { + httpBaseURL.port ?? (httpBaseURL.scheme == "https" ? 443 : 80) } public var baseURL: URL { - URL(string: "http://\(host):\(port)")! + httpBaseURL + } + + public var webSocketBaseURL: URL { + var components = URLComponents(url: httpBaseURL, resolvingAgainstBaseURL: false)! + components.scheme = httpBaseURL.scheme == "https" ? "wss" : "ws" + return components.url! + } + + public var isLoopback: Bool { + host == "127.0.0.1" || host == "::1" || host.lowercased() == "localhost" } } diff --git a/Sources/T3NotchCore/T3Connect.swift b/Sources/T3NotchCore/T3Connect.swift new file mode 100644 index 0000000..4be2b63 --- /dev/null +++ b/Sources/T3NotchCore/T3Connect.swift @@ -0,0 +1,613 @@ +import Foundation + +public struct T3ConnectConfiguration: Sendable, Equatable { + public let clerkPublishableKey: String + public let clerkJWTTemplate: String + public let relayURL: URL + public let clerkFrontendURL: URL + + public init( + clerkPublishableKey: String, + clerkJWTTemplate: String, + relayURL: URL + ) throws { + guard relayURL.scheme == "https", relayURL.host != nil else { + throw T3ConnectError.invalidConfiguration + } + guard let frontend = Self.frontendURL(from: clerkPublishableKey) else { + throw T3ConnectError.invalidConfiguration + } + self.clerkPublishableKey = clerkPublishableKey + self.clerkJWTTemplate = clerkJWTTemplate + self.relayURL = relayURL + self.clerkFrontendURL = frontend + } + + public static func load( + environment: [String: String] = ProcessInfo.processInfo.environment, + bundle: Bundle = .main + ) -> T3ConnectConfiguration? { + let key = environment["T3CODE_CLERK_PUBLISHABLE_KEY"] + ?? bundle.object(forInfoDictionaryKey: "T3ConnectClerkPublishableKey") as? String + let template = environment["T3CODE_CLERK_JWT_TEMPLATE"] + ?? bundle.object(forInfoDictionaryKey: "T3ConnectJWTTemplate") as? String + let relay = environment["T3CODE_RELAY_URL"] + ?? bundle.object(forInfoDictionaryKey: "T3ConnectRelayURL") as? String + guard let key, let template, let relay, let relayURL = URL(string: relay) else { + return nil + } + return try? T3ConnectConfiguration( + clerkPublishableKey: key, + clerkJWTTemplate: template, + relayURL: relayURL + ) + } + + private static func frontendURL(from publishableKey: String) -> URL? { + let parts = publishableKey.split(separator: "_", maxSplits: 2) + guard parts.count == 3 else { return nil } + var encoded = String(parts[2]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4) + guard let data = Data(base64Encoded: encoded), + var host = String(data: data, encoding: .utf8) + else { + return nil + } + host = host.trimmingCharacters(in: CharacterSet(charactersIn: "$")).lowercased() + guard Self.isValidASCIIHostname(host) else { return nil } + var components = URLComponents() + components.scheme = "https" + components.host = host + components.path = "/" + guard let url = components.url, url.host?.lowercased() == host else { + return nil + } + return url + } + + private static func isValidASCIIHostname(_ host: String) -> Bool { + guard !host.isEmpty, host.utf8.count <= 253, host.last != "." else { + return false + } + let labels = host.split(separator: ".", omittingEmptySubsequences: false) + guard !labels.isEmpty else { return false } + return labels.allSatisfy { label in + guard !label.isEmpty, + label.utf8.count <= 63, + let first = label.utf8.first, + let last = label.utf8.last, + Self.isASCIIAlphanumeric(first), + Self.isASCIIAlphanumeric(last) + else { + return false + } + return label.utf8.allSatisfy { + Self.isASCIIAlphanumeric($0) || $0 == 45 + } + } + } + + private static func isASCIIAlphanumeric(_ byte: UInt8) -> Bool { + (byte >= 97 && byte <= 122) || (byte >= 48 && byte <= 57) + } +} + +public struct T3ConnectEnvironment: Identifiable, Sendable, Equatable { + public let environmentID: EnvironmentID + public let label: String + public let endpoint: ServerEndpoint? + public let linkedAt: String? + + public var id: EnvironmentID { environmentID } + + public init( + environmentID: EnvironmentID, + label: String, + endpoint: ServerEndpoint?, + linkedAt: String? + ) { + self.environmentID = environmentID + self.label = label + self.endpoint = endpoint + self.linkedAt = linkedAt + } +} + +public enum T3ConnectError: Error, LocalizedError, Sendable { + case invalidConfiguration + case notImported + case invalidClerkSession + case unauthorized + case invalidResponse + case environmentOffline + case environmentMismatch + + public var errorDescription: String? { + switch self { + case .invalidConfiguration: "T3 Connect public configuration is unavailable." + case .notImported: "Import the T3 Code sign-in first." + case .invalidClerkSession: "T3 Code does not have an active compatible sign-in." + case .unauthorized: "The imported T3 Connect sign-in expired. Import it again." + case .invalidResponse: "T3 Connect returned an invalid response." + case .environmentOffline: "The T3 Connect environment is offline." + case .environmentMismatch: "T3 Connect returned a different environment identity." + } + } +} + +public actor T3ConnectClient { + private static let relayScopes = ["environment:status", "environment:connect"] + // Keep these aligned with the Clerk packages bundled by supported T3 Code builds. + private static let clerkAPIVersion = "2026-05-12" + private static let clerkJSVersion = "6.25.7" + private static let clerkElectronVersion = "0.0.18" + + private let configuration: T3ConnectConfiguration + private let vault: any RemoteCredentialStoring + private let signer: DPoPSigner + private let session: URLSession + private let requestObserver: (@Sendable (URLRequest) -> Void)? + + public init( + configuration: T3ConnectConfiguration, + vault: any RemoteCredentialStoring, + signer: DPoPSigner, + session: URLSession = .shared + ) { + self.configuration = configuration + self.vault = vault + self.signer = signer + self.session = session + self.requestObserver = nil + } + + init( + configuration: T3ConnectConfiguration, + vault: any RemoteCredentialStoring, + signer: DPoPSigner, + session: URLSession, + requestObserver: @escaping @Sendable (URLRequest) -> Void + ) { + self.configuration = configuration + self.vault = vault + self.signer = signer + self.session = session + self.requestObserver = requestObserver + } + + public func importSession(_ imported: ImportedElectronSession) throws { + try vault.update { document in + document.importedT3Connect = ImportedT3ConnectCredential( + clerkClientJWT: imported.clientJWT, + ciphertextFingerprint: imported.ciphertextFingerprint + ) + } + } + + public func forget() throws { + try vault.forgetT3Connect() + } + + public func listEnvironments() async throws -> [T3ConnectEnvironment] { + do { + let clerkToken = try await clerkTemplateToken() + var request = URLRequest( + url: configuration.relayURL.appendingPathComponent("v1/environments") + ) + request.timeoutInterval = 12 + request.setValue("Bearer \(clerkToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + let data = try await perform(request) + let response = try JSONDecoder().decode(RelayEnvironmentList.self, from: data) + return response.environments.map { record in + let endpoint = URL(string: record.endpoint.httpBaseURL) + .flatMap { try? ServerEndpoint(httpBaseURL: $0) } + return T3ConnectEnvironment( + environmentID: EnvironmentID(record.environmentID), + label: record.label, + endpoint: endpoint, + linkedAt: record.linkedAt + ) + } + } catch T3ConnectError.unauthorized { + try? vault.forgetT3Connect() + throw T3ConnectError.unauthorized + } + } + + public func status(_ environment: T3ConnectEnvironment) async throws -> Bool { + let url = configuration.relayURL + .appendingPathComponent("v1/environments") + .appendingPathComponent(environment.environmentID.rawValue) + .appendingPathComponent("status") + let data = try await authorizedRelayRequest(url: url, method: "POST", body: nil) + let response = try JSONDecoder().decode(RelayStatusResponse.self, from: data) + guard response.environmentID == environment.environmentID.rawValue else { + throw T3ConnectError.environmentMismatch + } + return response.status == "online" + } + + public func connect(_ environment: T3ConnectEnvironment) async throws -> RemotePairingResult { + guard try await status(environment) else { + throw T3ConnectError.environmentOffline + } + let url = configuration.relayURL + .appendingPathComponent("v1/environments") + .appendingPathComponent(environment.environmentID.rawValue) + .appendingPathComponent("connect") + let thumbprint = try await signer.thumbprint() + let payload = try JSONEncoder().encode( + RelayConnectRequest( + deviceID: Host.current().localizedName, + clientProofKeyThumbprint: thumbprint + ) + ) + let data = try await authorizedRelayRequest(url: url, method: "POST", body: payload) + let bootstrap = try JSONDecoder().decode(RelayConnectResponse.self, from: data) + guard bootstrap.environmentID == environment.environmentID.rawValue, + let endpointURL = URL(string: bootstrap.endpoint.httpBaseURL), + endpointURL.scheme == "https" + else { + throw T3ConnectError.environmentMismatch + } + let target = try RemotePairingTarget( + host: endpointURL.absoluteString, + pairingCode: bootstrap.credential + ) + let result = try await RemotePairingClient(session: session, signer: signer).pair( + target: target, + allowsInsecureHTTP: false, + source: .t3Connect, + expectedEnvironmentID: environment.environmentID + ) + try vault.update { document in + document.connectEnvironmentCredentials[environment.environmentID.rawValue] = + result.credential + } + return result + } + + private func authorizedRelayRequest( + url: URL, + method: String, + body: Data?, + canRetry: Bool = true + ) async throws -> Data { + let token = try await relayAccessToken(forceRefresh: false) + var request = URLRequest(url: url) + request.httpMethod = method + request.httpBody = body + request.timeoutInterval = 12 + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("DPoP \(token.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue( + try await signer.createProof(method: method, url: url, accessToken: token.accessToken), + forHTTPHeaderField: "DPoP" + ) + do { + return try await perform(request) + } catch T3ConnectError.unauthorized where canRetry { + try vault.update { $0.relayAccessTokens.removeAll() } + _ = try await relayAccessToken(forceRefresh: true) + return try await authorizedRelayRequest( + url: url, + method: method, + body: body, + canRetry: false + ) + } + } + + private func relayAccessToken(forceRefresh: Bool) async throws -> RemoteAccessCredential { + let cacheKey = Self.relayScopes.joined(separator: " ") + if !forceRefresh, + let cached = try vault.document().relayAccessTokens[cacheKey], + !cached.needsRefresh + { + return cached + } + let clerkToken = try await clerkTemplateToken() + let url = configuration.relayURL.appendingPathComponent("v1/client/dpop-token") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 12 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue( + try await signer.createProof(method: "POST", url: url), + forHTTPHeaderField: "DPoP" + ) + request.httpBody = Self.formEncoded([ + ("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"), + ("subject_token", clerkToken), + ("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"), + ( + "requested_token_type", + "urn:ietf:params:oauth:token-type:access_token" + ), + ("resource", configuration.relayURL.absoluteString), + ("scope", cacheKey), + ("client_id", "t3-web"), + ]) + let data = try await perform(request) + let response = try JSONDecoder().decode(RelayTokenResponse.self, from: data) + guard response.tokenType == "DPoP", + response.issuedTokenType + == "urn:ietf:params:oauth:token-type:access_token", + Set(response.scope.split(separator: " ").map(String.init)) == Set(Self.relayScopes) + else { + throw T3ConnectError.invalidResponse + } + let credential = RemoteAccessCredential( + accessToken: response.accessToken, + expiresAt: Date().addingTimeInterval(response.expiresIn), + source: .t3Connect + ) + try vault.update { $0.relayAccessTokens[cacheKey] = credential } + return credential + } + + private func clerkTemplateToken() async throws -> String { + guard let imported = try vault.document().importedT3Connect else { + throw T3ConnectError.notImported + } + try validateClerkClientJWT(imported.clerkClientJWT) + let clientURL = configuration.clerkFrontendURL.appendingPathComponent("v1/client") + var clientRequest = clerkRequest(url: clientURL, jwt: imported.clerkClientJWT) + clientRequest.httpMethod = "GET" + let (clientData, clientResponse) = try await performWithResponse(clientRequest) + let clientPayload = try JSONDecoder().decode( + ClerkResponseEnvelope.self, + from: clientData + ) + let client = clientPayload.response ?? clientPayload.value + guard let client else { + throw T3ConnectError.invalidResponse + } + let signedInSessions = client.sessions.filter { + ["active", "pending"].contains($0.status) + } + let lastActiveSession = client.lastActiveSessionID.flatMap { sessionID in + signedInSessions.first { $0.id == sessionID } + } + let selectedSession = lastActiveSession + ?? (signedInSessions.count == 1 ? signedInSessions[0] : nil) + guard let sessionID = selectedSession?.id else { + throw T3ConnectError.invalidClerkSession + } + let tokenURL = configuration.clerkFrontendURL + .appendingPathComponent("v1/client/sessions") + .appendingPathComponent(sessionID) + .appendingPathComponent("tokens") + .appendingPathComponent(configuration.clerkJWTTemplate) + let currentClientJWT = rotatedJWT( + from: clientResponse, + fallback: imported.clerkClientJWT + ) + var tokenRequest = clerkRequest(url: tokenURL, jwt: currentClientJWT) + tokenRequest.httpMethod = "POST" + tokenRequest.httpBody = Data() + tokenRequest.setValue( + "application/x-www-form-urlencoded", + forHTTPHeaderField: "Content-Type" + ) + let (tokenData, tokenResponse) = try await performWithResponse(tokenRequest) + let tokenPayload = try JSONDecoder().decode( + ClerkResponseEnvelope.self, + from: tokenData + ) + guard let token = tokenPayload.response ?? tokenPayload.value else { + throw T3ConnectError.invalidResponse + } + let rotated = rotatedJWT(from: tokenResponse, fallback: currentClientJWT) + try vault.update { document in + document.importedT3Connect?.clerkClientJWT = rotated + } + guard !token.jwt.isEmpty else { throw T3ConnectError.invalidResponse } + return token.jwt + } + + private func clerkRequest(url: URL, jwt: String) -> URLRequest { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return URLRequest(url: url) + } + var queryItems = components.queryItems ?? [] + queryItems.append(contentsOf: [ + URLQueryItem(name: "__clerk_api_version", value: Self.clerkAPIVersion), + URLQueryItem(name: "_clerk_js_version", value: Self.clerkJSVersion), + URLQueryItem(name: "_is_native", value: "1"), + URLQueryItem( + name: "_electron_sdk_version", + value: Self.clerkElectronVersion + ), + ]) + components.queryItems = queryItems + var request = URLRequest(url: components.url ?? url) + request.timeoutInterval = 12 + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + return request + } + + /// The Clerk Frontend API is the authority for whether a restored client JWT + /// belongs to this instance. Clerk client JWT issuers are not guaranteed to + /// equal a custom Frontend API hostname, so host equality here would reject + /// sessions that the official Electron SDK accepts. + private func validateClerkClientJWT(_ jwt: String) throws { + let parts = jwt.split(separator: ".") + guard parts.count == 3, + let payload = Self.decodeBase64URL(String(parts[1])), + (try JSONSerialization.jsonObject(with: payload)) is [String: Any] + else { + throw T3ConnectError.invalidClerkSession + } + } + + private func perform(_ request: URLRequest) async throws -> Data { + try await performWithResponse(request).0 + } + + private func performWithResponse( + _ request: URLRequest + ) async throws -> (Data, HTTPURLResponse) { + requestObserver?(request) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw T3ConnectError.invalidResponse + } + if http.statusCode == 401 || http.statusCode == 403 { + throw T3ConnectError.unauthorized + } + guard (200..<300).contains(http.statusCode) else { + throw T3ConnectError.invalidResponse + } + return (data, http) + } + + private func rotatedJWT(from response: HTTPURLResponse, fallback: String) -> String { + guard let header = response.value(forHTTPHeaderField: "Authorization")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !header.isEmpty + else { + return fallback + } + return header.hasPrefix("Bearer ") ? String(header.dropFirst(7)) : header + } + + private static func formEncoded(_ fields: [(String, String)]) -> Data { + FormURLEncoding.data(fields) + } + + private static func decodeBase64URL(_ value: String) -> Data? { + var encoded = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4) + return Data(base64Encoded: encoded) + } +} + +private struct ClerkResponseEnvelope: Decodable { + let response: Value? + let value: Value? + + init(from decoder: Decoder) throws { + if let container = try? decoder.container(keyedBy: CodingKeys.self), + container.contains(.response) + { + response = try container.decodeIfPresent(Value.self, forKey: .response) + value = nil + } else { + response = nil + value = try Value(from: decoder) + } + } + + private enum CodingKeys: String, CodingKey { + case response + } +} + +private struct ClerkClient: Decodable { + struct Session: Decodable { + let id: String + let status: String + } + + let lastActiveSessionID: String? + let sessions: [Session] + + enum CodingKeys: String, CodingKey { + case lastActiveSessionID = "last_active_session_id" + case sessions + } +} + +private struct ClerkToken: Decodable { + let jwt: String +} + +private struct RelayEnvironmentList: Decodable { + let environments: [RelayEnvironmentRecord] +} + +private struct RelayEnvironmentRecord: Decodable { + let environmentID: String + let label: String + let endpoint: RelayEndpoint + let linkedAt: String? + + enum CodingKeys: String, CodingKey { + case environmentID = "environmentId" + case label + case endpoint + case linkedAt + } +} + +private struct RelayEndpoint: Codable { + let httpBaseURL: String + let wsBaseURL: String? + let providerKind: String? + + enum CodingKeys: String, CodingKey { + case httpBaseURL = "httpBaseUrl" + case wsBaseURL = "wsBaseUrl" + case providerKind + } +} + +private struct RelayTokenResponse: Decodable { + let accessToken: String + let issuedTokenType: String + let tokenType: String + let expiresIn: TimeInterval + let scope: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case issuedTokenType = "issued_token_type" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scope + } +} + +private struct RelayStatusResponse: Decodable { + let environmentID: String + let status: String + + enum CodingKeys: String, CodingKey { + case environmentID = "environmentId" + case status + } +} + +private struct RelayConnectRequest: Encodable { + let deviceID: String? + let clientProofKeyThumbprint: String + + enum CodingKeys: String, CodingKey { + case deviceID = "deviceId" + case clientProofKeyThumbprint + } +} + +private struct RelayConnectResponse: Decodable { + let environmentID: String + let endpoint: RelayEndpoint + let credential: String + let expiresAt: String + + enum CodingKeys: String, CodingKey { + case environmentID = "environmentId" + case endpoint + case credential + case expiresAt + } +} diff --git a/Sources/T3NotchCore/T3HTTPClient.swift b/Sources/T3NotchCore/T3HTTPClient.swift index b23bd8c..97fc1db 100644 --- a/Sources/T3NotchCore/T3HTTPClient.swift +++ b/Sources/T3NotchCore/T3HTTPClient.swift @@ -6,51 +6,151 @@ public enum T3HTTPError: Error, LocalizedError, Sendable { case httpStatus(Int, String) case decoding(Error) case transport(Error) + case authorization(Error) public var errorDescription: String? { switch self { case .invalidURL: return "Invalid URL" case .unauthorized: - return "Unauthorized — mint a new bearer token" + return "The environment rejected this session." case let .httpStatus(code, body): return "HTTP \(code): \(body.prefix(200))" case let .decoding(error): return "Decode failed: \(error.localizedDescription)" - case let .transport(error): + case let .transport(error), let .authorization(error): return error.localizedDescription } } } +public protocol HTTPAuthorizer: Sendable { + func authorize(_ request: URLRequest) async throws -> URLRequest +} + +public struct NoHTTPAuthorizer: HTTPAuthorizer { + public init() {} + public func authorize(_ request: URLRequest) async throws -> URLRequest { request } +} + +public struct BearerHTTPAuthorizer: HTTPAuthorizer { + private let token: String + + public init(token: String) { + self.token = token + } + + public func authorize(_ request: URLRequest) async throws -> URLRequest { + var request = request + if !token.isEmpty { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + return request + } +} + +public actor DPoPHTTPAuthorizer: HTTPAuthorizer { + private var accessToken: String + private let signer: DPoPSigner + + public init(accessToken: String, signer: DPoPSigner) { + self.accessToken = accessToken + self.signer = signer + } + + public func update(accessToken: String) { + self.accessToken = accessToken + } + + public func authorize(_ request: URLRequest) async throws -> URLRequest { + guard let url = request.url else { throw T3HTTPError.invalidURL } + var request = request + let token = accessToken + let proof = try await signer.createProof( + method: request.httpMethod ?? "GET", + url: url, + accessToken: token + ) + request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") + request.setValue(proof, forHTTPHeaderField: "DPoP") + return request + } +} + public actor T3HTTPClient { public private(set) var endpoint: ServerEndpoint - public private(set) var token: String + private var authorizer: any HTTPAuthorizer private let session: URLSession private let decoder: JSONDecoder private let encoder: JSONEncoder + public init( + endpoint: ServerEndpoint, + authorizer: any HTTPAuthorizer, + session: URLSession = .shared + ) { + self.endpoint = endpoint + self.authorizer = authorizer + self.session = session + self.decoder = JSONDecoder() + self.encoder = JSONEncoder() + } + + /// Compatibility initializer for the local auto-minted bearer session. public init( endpoint: ServerEndpoint, token: String, session: URLSession = .shared ) { self.endpoint = endpoint - self.token = token + self.authorizer = BearerHTTPAuthorizer(token: token) self.session = session self.decoder = JSONDecoder() self.encoder = JSONEncoder() } + public func update(endpoint: ServerEndpoint, authorizer: any HTTPAuthorizer) { + self.endpoint = endpoint + self.authorizer = authorizer + } + public func update(endpoint: ServerEndpoint, token: String) { self.endpoint = endpoint - self.token = token + self.authorizer = BearerHTTPAuthorizer(token: token) } public func fetchEnvironment() async throws -> EnvironmentDescriptor { try await get(path: "/.well-known/t3/environment") } + /// Checks the credential at the endpoint that actually enforces session + /// authorization. The descriptor is intentionally public and is therefore + /// not sufficient verification after a one-time pairing exchange. + public func verifySession() async throws { + let request = try await makeRequest( + path: "/api/auth/session", + method: "GET", + body: nil as Data? + ) + do { + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw T3HTTPError.httpStatus(-1, "No HTTP response") + } + if http.statusCode == 401 || http.statusCode == 403 { + throw T3HTTPError.unauthorized + } + guard (200..<300).contains(http.statusCode) else { + // Do not attach the session response body to an error. + throw T3HTTPError.httpStatus(http.statusCode, "") + } + } catch let error as T3HTTPError { + throw error + } catch { + throw T3HTTPError.transport(error) + } + } + public func fetchShell() async throws -> ShellSnapshot { try await get(path: "/api/orchestration/shell") } @@ -64,31 +164,34 @@ public actor T3HTTPClient { try await post(path: "/api/orchestration/dispatch", body: command) } - private func get(path: String) async throws -> T { - let request = try makeRequest(path: path, method: "GET", body: nil as Data?) + public func get(path: String) async throws -> T { + let request = try await makeRequest(path: path, method: "GET", body: nil as Data?) return try await perform(request) } - private func post(path: String, body: Body) async throws -> T { + public func post(path: String, body: Body) async throws -> T { let data = try encoder.encode(body) - let request = try makeRequest(path: path, method: "POST", body: data) + let request = try await makeRequest(path: path, method: "POST", body: data) return try await perform(request) } - private func makeRequest(path: String, method: String, body: Data?) throws -> URLRequest { + private func makeRequest(path: String, method: String, body: Data?) async throws -> URLRequest { guard let url = URL(string: path, relativeTo: endpoint.baseURL) else { throw T3HTTPError.invalidURL } var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = 8 - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") if let body { request.httpBody = body request.setValue("application/json", forHTTPHeaderField: "Content-Type") } - return request + do { + return try await authorizer.authorize(request) + } catch { + throw T3HTTPError.authorization(error) + } } private func perform(_ request: URLRequest) async throws -> T { @@ -109,7 +212,6 @@ public actor T3HTTPClient { let body = String(data: data, encoding: .utf8) ?? "" throw T3HTTPError.httpStatus(http.statusCode, body) } - // Empty success bodies (rare). if data.isEmpty, T.self == DispatchResult.self { return DispatchResult(ok: true) as! T } diff --git a/Sources/T3NotchCore/T3Transport.swift b/Sources/T3NotchCore/T3Transport.swift index 77139a1..60b19a1 100644 --- a/Sources/T3NotchCore/T3Transport.swift +++ b/Sources/T3NotchCore/T3Transport.swift @@ -18,6 +18,47 @@ public enum ConnectionState: String, Sendable, Equatable { case unauthorized } +public struct PollingConfiguration: Sendable { + public var activeShellNanoseconds: UInt64 + public var idleShellNanoseconds: UInt64 + public var focusedDetailNanoseconds: UInt64 + public var idleDetailNanoseconds: UInt64 + public var maximumBackoffNanoseconds: UInt64 + public var sleep: @Sendable (UInt64) async -> Void + public var jitter: @Sendable (UInt64) -> UInt64 + public var onBackoff: @Sendable (UInt64) -> Void + + public init( + activeShellNanoseconds: UInt64 = 800_000_000, + idleShellNanoseconds: UInt64 = 3_000_000_000, + focusedDetailNanoseconds: UInt64 = 400_000_000, + idleDetailNanoseconds: UInt64 = 2_000_000_000, + maximumBackoffNanoseconds: UInt64 = 30_000_000_000, + sleep: @escaping @Sendable (UInt64) async -> Void = { + try? await Task.sleep(nanoseconds: $0) + }, + jitter: @escaping @Sendable (UInt64) -> UInt64 = { value in + guard value > 10 else { return value } + let spread = value / 10 + return UInt64.random(in: (value - spread)...(value + spread)) + }, + onBackoff: @escaping @Sendable (UInt64) -> Void = { _ in } + ) { + self.activeShellNanoseconds = activeShellNanoseconds + self.idleShellNanoseconds = idleShellNanoseconds + self.focusedDetailNanoseconds = focusedDetailNanoseconds + self.idleDetailNanoseconds = idleDetailNanoseconds + self.maximumBackoffNanoseconds = maximumBackoffNanoseconds + self.sleep = sleep + self.jitter = jitter + self.onBackoff = onBackoff + } + + public static var remote: PollingConfiguration { + PollingConfiguration(idleShellNanoseconds: 5_000_000_000) + } +} + /// Adaptive HTTP polling transport over t3code's public orchestration API. public final class PollingTransport: T3Transport, @unchecked Sendable { private struct State { @@ -29,23 +70,40 @@ public final class PollingTransport: T3Transport, @unchecked Sendable { var connectionState: ConnectionState = .connecting var detailTasks: [String: Task] = [:] var detailContinuations: [String: AsyncStream.Continuation] = [:] + var onConnectionStateChange: (@Sendable (ConnectionState) -> Void)? + var onRepeatedFailure: (@Sendable () -> Void)? } private let client: T3HTTPClient + private let configuration: PollingConfiguration private let shellContinuation: AsyncStream.Continuation public let shell: AsyncStream private let state = OSAllocatedUnfairLock(initialState: State()) private var shellTask: Task? - public var onConnectionStateChange: (@Sendable (ConnectionState) -> Void)? + public var onConnectionStateChange: (@Sendable (ConnectionState) -> Void)? { + get { state.withLock(\.onConnectionStateChange) } + set { state.withLock { $0.onConnectionStateChange = newValue } } + } + /// Called after a second and subsequent consecutive failure. Coordinators + /// use this signal to apply path-failover thresholds without making the + /// public connection state chatter on every backoff attempt. + public var onRepeatedFailure: (@Sendable () -> Void)? { + get { state.withLock(\.onRepeatedFailure) } + set { state.withLock { $0.onRepeatedFailure = newValue } } + } public var connectionState: ConnectionState { state.withLock(\.connectionState) } - public init(client: T3HTTPClient) { + public init( + client: T3HTTPClient, + configuration: PollingConfiguration = PollingConfiguration() + ) { self.client = client + self.configuration = configuration let (stream, continuation) = AsyncStream.makeStream() self.shell = stream self.shellContinuation = continuation @@ -152,20 +210,24 @@ public final class PollingTransport: T3Transport, @unchecked Sendable { shellContinuation.yield(snapshot) } - let interval: UInt64 = active ? 800_000_000 : 3_000_000_000 + let interval = active + ? configuration.activeShellNanoseconds + : configuration.idleShellNanoseconds await sleepInterruptible(nanoseconds: interval) } catch let error as T3HTTPError { if case .unauthorized = error { setConnectionState(.unauthorized) } else { - setConnectionState(.disconnected) + if !setConnectionState(.disconnected) { + state.withLock(\.onRepeatedFailure)?() + } } - backoffNanos = min(max(backoffNanos * 2, 500_000_000), 10_000_000_000) - try? await Task.sleep(nanoseconds: backoffNanos) + await applyBackoff(&backoffNanos) } catch { - setConnectionState(.disconnected) - backoffNanos = min(max(backoffNanos * 2, 500_000_000), 10_000_000_000) - try? await Task.sleep(nanoseconds: backoffNanos) + if !setConnectionState(.disconnected) { + state.withLock(\.onRepeatedFailure)?() + } + await applyBackoff(&backoffNanos) } } } @@ -183,7 +245,9 @@ public final class PollingTransport: T3Transport, @unchecked Sendable { // Only the focused thread's detail is ever displayed, so other // subscriptions idle instead of polling alongside it. if focused != threadId { - try? await Task.sleep(nanoseconds: 2_000_000_000) + await sleepInterruptible( + nanoseconds: configuration.idleDetailNanoseconds + ) continue } @@ -193,24 +257,31 @@ public final class PollingTransport: T3Transport, @unchecked Sendable { lastSequence = detail.snapshotSequence continuation.yield(detail) } - let interval: UInt64 = (expanded || active) ? 400_000_000 : 2_000_000_000 + let interval = (expanded || active) + ? configuration.focusedDetailNanoseconds + : configuration.idleDetailNanoseconds await sleepInterruptible(nanoseconds: interval) } catch { - try? await Task.sleep(nanoseconds: 2_000_000_000) + await sleepInterruptible( + nanoseconds: configuration.idleDetailNanoseconds + ) } } continuation.finish() } - private func setConnectionState(_ newState: ConnectionState) { - let changed = state.withLock { state -> Bool in + @discardableResult + private func setConnectionState(_ newState: ConnectionState) -> Bool { + let (changed, callback) = state.withLock { state + -> (Bool, (@Sendable (ConnectionState) -> Void)?) in let changed = state.connectionState != newState state.connectionState = newState - return changed + return (changed, state.onConnectionStateChange) } if changed { - onConnectionStateChange?(newState) + callback?(newState) } + return changed } private func sleepInterruptible(nanoseconds: UInt64) async { @@ -220,8 +291,18 @@ public final class PollingTransport: T3Transport, @unchecked Sendable { if Task.isCancelled { return } if state.withLock(\.forcePoll) { return } let step = min(slice, remaining) - try? await Task.sleep(nanoseconds: step) + await configuration.sleep(step) remaining -= step } } + + private func applyBackoff(_ backoffNanos: inout UInt64) async { + backoffNanos = min( + max(backoffNanos * 2, 500_000_000), + configuration.maximumBackoffNanoseconds + ) + let delay = configuration.jitter(backoffNanos) + configuration.onBackoff(delay) + await sleepInterruptible(nanoseconds: delay) + } } diff --git a/Tests/T3NotchCoreTests/RemoteSupportTests.swift b/Tests/T3NotchCoreTests/RemoteSupportTests.swift new file mode 100644 index 0000000..42bf5e0 --- /dev/null +++ b/Tests/T3NotchCoreTests/RemoteSupportTests.swift @@ -0,0 +1,944 @@ +import CryptoKit +import Foundation +import Testing +@testable import T3NotchCore + +private final class MockRemoteURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var handler: + (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + do { + guard let handler = Self.handler else { + throw URLError(.badServerResponse) + } + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +private final class RequestLog: @unchecked Sendable { + private let lock = NSLock() + private var storage: [URLRequest] = [] + + func append(_ request: URLRequest) { + var captured = request + if captured.httpBody == nil, let stream = captured.httpBodyStream { + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count > 0 else { break } + data.append(buffer, count: count) + } + captured.httpBody = data + captured.httpBodyStream = nil + } + lock.withLock { storage.append(captured) } + } + + var requests: [URLRequest] { + lock.withLock { storage } + } +} + +private final class UIntLog: @unchecked Sendable { + private let lock = NSLock() + private var values: [UInt64] = [] + + func append(_ value: UInt64) { + lock.withLock { values.append(value) } + } + + var snapshot: [UInt64] { lock.withLock { values } } +} + +private final class MemoryCredentialStore: RemoteCredentialStoring, @unchecked Sendable { + private let lock = NSLock() + private var value: RemoteCredentialDocument + + init(_ value: RemoteCredentialDocument = RemoteCredentialDocument()) { + self.value = value + } + + func document() throws -> RemoteCredentialDocument { + lock.withLock { value } + } + + func update( + _ transform: (inout RemoteCredentialDocument) throws -> Void + ) throws { + try lock.withLock { + try transform(&value) + } + } + + func forgetT3Connect() throws { + lock.withLock { + value.importedT3Connect = nil + value.relayAccessTokens = [:] + value.connectEnvironmentCredentials = [:] + } + } +} + +private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() -> Int { + lock.withLock { + value += 1 + return value + } + } + + var count: Int { lock.withLock { value } } +} + +private final class BooleanFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + lock.withLock { value = true } + } + + var isSet: Bool { lock.withLock { value } } +} + +@Suite("Remote support", .serialized) +struct RemoteSupportTests { + @Test func formURLEncodingUsesHTMLFormRules() { + let body = FormURLEncoding.data([ + ("space", "a b"), + ("asterisk", "*"), + ("tilde", "~"), + ]) + + #expect( + String(decoding: body, as: UTF8.self) + == "space=a+b&asterisk=*&tilde=%7E" + ) + } + + @Test func canonicalizesHTTPAndDerivesWebSocketEndpoint() throws { + let endpoint = try ServerEndpoint( + httpBaseURL: #require(URL(string: "HTTPS://mini.example.com:8443/pair?q=secret#token")) + ) + #expect(endpoint.httpBaseURL.absoluteString == "https://mini.example.com:8443/") + #expect(endpoint.webSocketBaseURL.absoluteString == "wss://mini.example.com:8443/") + #expect(endpoint.port == 8443) + #expect(!endpoint.isLoopback) + #expect(ServerEndpoint(host: "::1", port: 3773).isLoopback) + #expect(throws: ServerEndpointError.self) { + try ServerEndpoint( + httpBaseURL: #require(URL(string: "https://user:secret@mini.example")) + ) + } + } + + @Test func connectConfigurationRejectsNonHostnameClerkFrontends() throws { + let invalidHosts = [ + "attacker.example?x=$", + "user@attacker.example$", + "-invalid.example$", + "invalid..example$", + ] + for host in invalidHosts { + let encoded = Data(host.utf8).base64URLEncodedString() + #expect(throws: T3ConnectError.self) { + try T3ConnectConfiguration( + clerkPublishableKey: "pk_test_\(encoded)", + clerkJWTTemplate: "t3-relay", + relayURL: #require(URL(string: "https://relay.example/")) + ) + } + } + } + + @Test func parsesAndSanitizesDirectAndHostedPairingLinks() throws { + let direct = try RemotePairingTarget( + pairingURL: "http://192.168.1.8:3773/pair?token=query-secret" + ) + #expect(direct.credential == "query-secret") + #expect(direct.endpoint.httpBaseURL.absoluteString == "http://192.168.1.8:3773/") + #expect(!direct.endpoint.httpBaseURL.absoluteString.contains("secret")) + + let hosted = try RemotePairingTarget( + pairingURL: + "https://app.t3.codes/pair?host=https%3A%2F%2Fmini.tailnet.ts.net" + + "#token=fragment-secret" + ) + #expect(hosted.credential == "fragment-secret") + #expect(hosted.endpoint.httpBaseURL.absoluteString == "https://mini.tailnet.ts.net/") + #expect(!hosted.endpoint.httpBaseURL.absoluteString.contains("token")) + #expect(!hosted.endpoint.httpBaseURL.absoluteString.contains("secret")) + } + + @Test func parsesAdvancedIPv4IPv6AndDNSHosts() throws { + let ipv4 = try RemotePairingTarget(host: "10.0.0.4:3773", pairingCode: "one") + let ipv6 = try RemotePairingTarget(host: "http://[2001:db8::1]:3773", pairingCode: "two") + let dns = try RemotePairingTarget( + host: "mini.example.test", + pairingCode: "three" + ) + #expect(ipv4.endpoint.httpBaseURL.absoluteString == "https://10.0.0.4:3773/") + #expect(ipv6.endpoint.host == "2001:db8::1") + #expect(dns.endpoint.httpBaseURL.absoluteString == "https://mini.example.test/") + } + + @Test func rejectsNonLoopbackHTTPWithoutExplicitAcknowledgement() async throws { + let signer = try DPoPSigner() + let target = try RemotePairingTarget( + host: "http://192.168.1.9:3773", + pairingCode: "do-not-retain" + ) + do { + _ = try await RemotePairingClient(signer: signer).pair(target: target) + Issue.record("Expected insecure HTTP to be rejected") + } catch { + guard case RemotePairingError.insecureHTTPNeedsConfirmation = error else { + Issue.record("Expected the explicit insecure HTTP error") + return + } + #expect(!error.localizedDescription.contains("do-not-retain")) + } + } + + @Test func dpopProofNormalizesHTUAndUsesRawES256Signature() async throws { + let signer = try DPoPSigner() + let now = Date(timeIntervalSince1970: 1_800_000_000) + let token = "access-token" + let proof = try await signer.createProof( + method: "post", + url: #require(URL(string: "https://MINI.example:443/oauth/token?q=secret#fragment")), + accessToken: token, + now: now, + jti: #require(UUID(uuidString: "11111111-2222-3333-4444-555555555555")) + ) + let parts = proof.split(separator: ".") + #expect(parts.count == 3) + let header = try jsonObject(String(parts[0])) + let payload = try jsonObject(String(parts[1])) + #expect(header["typ"] as? String == "dpop+jwt") + #expect(header["alg"] as? String == "ES256") + #expect((header["jwk"] as? [String: Any])?["crv"] as? String == "P-256") + #expect(payload["htm"] as? String == "POST") + #expect(payload["htu"] as? String == "https://mini.example/oauth/token") + #expect(payload["iat"] as? Int == 1_800_000_000) + #expect(payload["jti"] as? String == "11111111-2222-3333-4444-555555555555") + let ath = Data(SHA256.hash(data: Data(token.utf8))).base64URLEncodedString() + #expect(payload["ath"] as? String == ath) + #expect(try decodeBase64URL(String(parts[2])).count == 64) + } + + @Test func dpopUsesRFC7638ThumbprintsAndFreshJTIValues() async throws { + let signer = try DPoPSigner() + let jwk = try await signer.publicJWK() + let canonical = #"{"crv":"P-256","kty":"EC","x":"\#(jwk.x)","y":"\#(jwk.y)"}"# + let expected = Data(SHA256.hash(data: Data(canonical.utf8))) + .base64URLEncodedString() + #expect(try await signer.thumbprint() == expected) + + let url = try #require(URL(string: "https://mini.example/api/orchestration/shell")) + let first = try await signer.createProof(method: "GET", url: url) + let second = try await signer.createProof(method: "GET", url: url) + let firstPayload = try jsonObject(String(first.split(separator: ".")[1])) + let secondPayload = try jsonObject(String(second.split(separator: ".")[1])) + #expect(firstPayload["jti"] as? String != secondPayload["jti"] as? String) + } + + @Test func authorizerAddsFreshDPoPAndNeverChangesTheURL() async throws { + let signer = try DPoPSigner() + let authorizer = DPoPHTTPAuthorizer(accessToken: "bound-token", signer: signer) + let url = try #require(URL(string: "https://mini.example/api")) + var original = URLRequest(url: url) + original.httpMethod = "get" + let first = try await authorizer.authorize(original) + let second = try await authorizer.authorize(original) + #expect(first.url == url) + #expect(first.value(forHTTPHeaderField: "Authorization") == "DPoP bound-token") + #expect(first.value(forHTTPHeaderField: "DPoP") != second.value(forHTTPHeaderField: "DPoP")) + } + + @Test func scopedIdentitiesPreventCrossMachineCollisions() { + let first = ScopedThreadID( + environmentID: EnvironmentID("mini-a"), + threadID: "thread-1" + ) + let second = ScopedThreadID( + environmentID: EnvironmentID("mini-b"), + threadID: "thread-1" + ) + #expect(first != second) + #expect(first.storageKey != second.storageKey) + #expect(Set([first, second]).count == 2) + #expect( + ScopedRequestID(thread: first, requestID: "request") + != ScopedRequestID(thread: second, requestID: "request") + ) + } + + @Test func profileStorePersistsOnlyNonSecretConfiguration() throws { + let suite = "RemoteSupportTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = EnvironmentProfileStore(defaults: defaults, key: "profiles") + let profile = EnvironmentProfile( + environmentID: EnvironmentID("mini"), + label: "Build mini", + directEndpoint: try ServerEndpoint( + httpBaseURL: #require(URL(string: "https://mini.example")) + ), + source: .direct, + enabled: false, + allowsInsecureHTTP: true + ) + try store.upsert(profile) + #expect(store.load() == [profile]) + let bytes = try #require(defaults.data(forKey: "profiles")) + let serialized = try #require(String(data: bytes, encoding: .utf8)) + #expect(!serialized.localizedCaseInsensitiveContains("token")) + #expect(!serialized.localizedCaseInsensitiveContains("credential")) + try store.remove(profile.environmentID) + #expect(store.load().isEmpty) + } + + @Test func credentialDocumentMigratesAndSeparatesDirectFromConnectTokens() throws { + let legacy = Data( + """ + { + "version": 1, + "environmentCredentials": { + "mini": { + "accessToken": "direct", + "expiresAt": 800000000, + "source": "direct" + } + }, + "relayAccessTokens": {} + } + """.utf8 + ) + var document = try JSONDecoder().decode(RemoteCredentialDocument.self, from: legacy) + #expect(document.environmentCredentials["mini"]?.accessToken == "direct") + #expect(document.connectEnvironmentCredentials.isEmpty) + document.connectEnvironmentCredentials["mini"] = RemoteAccessCredential( + accessToken: "connect", + expiresAt: .distantFuture, + source: .t3Connect + ) + #expect(document.environmentCredentials["mini"]?.accessToken == "direct") + #expect(document.connectEnvironmentCredentials["mini"]?.accessToken == "connect") + let roundTrip = try JSONDecoder().decode( + RemoteCredentialDocument.self, + from: JSONEncoder().encode(document) + ) + #expect(roundTrip.version == RemoteCredentialDocument().version) + #expect(roundTrip.environmentCredentials["mini"]?.accessToken == "direct") + #expect(roundTrip.connectEnvironmentCredentials["mini"]?.accessToken == "connect") + } + + @Test func electronV10FixtureDecryptsAndBadPaddingFailsClosed() throws { + let ciphertext = try #require( + Data( + base64Encoded: + "qPuLJc1rfU5oMAZrXr+tZj73wg3t1E/d2w8h/hp8ODtm1jZymlm2llFkj9IzVEVl" + + "SaCOshwel9iUDTb9FiH0aKeOXl9xviZHzHPatsw3reQaU4PbaCkbO7cJhopF04+R" + ) + ) + let decrypted = try ElectronSafeStorageImporter.decryptV10( + ciphertext, + password: Data("peanuts".utf8) + ) + #expect( + decrypted + == "eyJhbGciOiJub25lIn0." + + "eyJpc3MiOiJodHRwczovL2NsZXJrLmV4YW1wbGUiLCJzdWIiOiJ1c2VyIn0." + + "signature" + ) + var corrupted = ciphertext + corrupted[corrupted.index(before: corrupted.endIndex)] ^= 0xff + #expect(throws: ElectronSafeStorageError.self) { + try ElectronSafeStorageImporter.decryptV10( + corrupted, + password: Data("peanuts".utf8) + ) + } + } + + @Test func electronDetectionRejectsUnsafeFilesAndNeverDecrypts() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("T3Notch-import-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("clerk-tokens.json") + let json = #"{"__clerk_client_jwt":"enc:djEwY2lwaGVydGV4dA=="}"# + try Data(json.utf8).write(to: file) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o600))], + ofItemAtPath: file.path + ) + let importer = ElectronSafeStorageImporter(tokenFile: file) + if case .signedIn(let fingerprint) = importer.detect() { + #expect(!fingerprint.isEmpty) + } else { + Issue.record("A safe recognized record should be detected without Keychain access") + } + + try Data(#"{"__clerk_client_jwt":"plaintext"}"#.utf8).write(to: file) + if case .incompatible = importer.detect() { + // expected + } else { + Issue.record("Unsupported token formats must be rejected during detection") + } + try Data(json.utf8).write(to: file) + + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o666))], + ofItemAtPath: file.path + ) + if case .unsafePermissions = importer.detect() { + // expected + } else { + Issue.record("Group/world-writable token files must fail closed") + } + + let link = directory.appendingPathComponent("linked.json") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: file) + if case .incompatible = ElectronSafeStorageImporter(tokenFile: link).detect() { + // expected + } else { + Issue.record("Symlink token files must fail closed") + } + } + + @Test func pairingExchangeUsesExactOAuthFieldsAndVerifiesSession() async throws { + let log = RequestLog() + let oauthLog = RequestLog() + let session = mockSession { request in + log.append(request) + let path = request.url?.path ?? "" + let data: Data + if path == "/oauth/token" { + data = Data( + """ + { + "access_token": "issued-access-token", + "token_type": "DPoP", + "expires_in": 3600, + "scope": "orchestration:read orchestration:operate" + } + """.utf8 + ) + } else if path == "/api/auth/session" { + data = Data(#"{"authenticated":true}"#.utf8) + } else { + data = Data( + """ + { + "environmentId": "stable-mini", + "label": "Mac mini", + "platform": {"os": "darwin", "arch": "arm64"}, + "serverVersion": "1.2.3" + } + """.utf8 + ) + } + return ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + data + ) + } + defer { MockRemoteURLProtocol.handler = nil } + let signer = try DPoPSigner() + let result = try await RemotePairingClient( + session: session, + signer: signer, + requestObserver: { oauthLog.append($0) } + ).pair( + target: try RemotePairingTarget( + host: "https://mini.example", + pairingCode: "single+use&secret" + ) + ) + #expect(result.profile.environmentID == EnvironmentID("stable-mini")) + #expect(result.credential.source == .direct) + + let requests = log.requests + #expect(requests.map(\.url?.path) == [ + "/.well-known/t3/environment", + "/oauth/token", + "/api/auth/session", + "/.well-known/t3/environment", + ]) + let tokenRequest = try #require(oauthLog.requests.first) + let fields = formFields(try #require(tokenRequest.httpBody)) + #expect(fields["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange") + #expect( + fields["subject_token_type"] + == "urn:t3:params:oauth:token-type:environment-bootstrap" + ) + #expect(fields["subject_token"] == "single+use&secret") + #expect(fields["scope"] == "orchestration:read orchestration:operate") + #expect(fields["client_label"] == "T3Notch") + #expect(fields["client_device_type"] == "desktop") + #expect(fields["client_os"] == "macOS") + #expect(tokenRequest.value(forHTTPHeaderField: "DPoP") != nil) + let verification = try #require( + requests.first { $0.url?.path == "/api/auth/session" } + ) + #expect(verification.value(forHTTPHeaderField: "Authorization") == "DPoP issued-access-token") + #expect(verification.value(forHTTPHeaderField: "DPoP") != nil) + for request in requests where request.url?.path != "/oauth/token" { + #expect(!request.url!.absoluteString.contains("single-use-secret")) + } + } + + @Test func pollingBackoffUsesInjectedSleeperAndCapsFromHalfASecond() async throws { + let session = mockSession { request in + ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 503, + httpVersion: nil, + headerFields: nil + )!, + Data() + ) + } + defer { MockRemoteURLProtocol.handler = nil } + let backoffs = UIntLog() + let configuration = PollingConfiguration( + activeShellNanoseconds: 1, + idleShellNanoseconds: 1, + focusedDetailNanoseconds: 1, + idleDetailNanoseconds: 1, + maximumBackoffNanoseconds: 2_000_000_000, + sleep: { _ in + if backoffs.snapshot.count >= 3 { + try? await Task.sleep(for: .seconds(1)) + } + }, + jitter: { $0 }, + onBackoff: { backoffs.append($0) } + ) + let transport = PollingTransport( + client: T3HTTPClient( + endpoint: try ServerEndpoint( + httpBaseURL: #require(URL(string: "https://offline.example")) + ), + token: "token", + session: session + ), + configuration: configuration + ) + defer { transport.stop() } + for _ in 0..<250 { + if backoffs.snapshot.count >= 3 { break } + try await Task.sleep(for: .milliseconds(20)) + } + #expect( + backoffs.snapshot.count >= 3, + "Timed out waiting for three backoff samples" + ) + #expect(Array(backoffs.snapshot.prefix(3)) == [ + 500_000_000, + 1_000_000_000, + 2_000_000_000, + ]) + } + + @Test func completeConnectChainSelectsSessionRotatesAndCachesTokens() async throws { + let configuration = try connectConfiguration() + var document = RemoteCredentialDocument() + document.importedT3Connect = ImportedT3ConnectCredential( + clerkClientJWT: clerkClientJWT(subject: "user"), + ciphertextFingerprint: "fingerprint" + ) + let store = MemoryCredentialStore(document) + let observed = RequestLog() + let network = RequestLog() + let session = mockSession { request in + network.append(request) + let path = request.url?.path ?? "" + let host = request.url?.host + let body: String + var headers = ["Content-Type": "application/json"] + switch (host, path) { + case ("clerk.example", "/v1/client"): + body = + #"{"response":{"last_active_session_id":"session-1","sessions":[{"id":"session-1","status":"active"}]}}"# + headers["Authorization"] = "Bearer \(clerkClientJWT(subject: "rotated"))" + case ("clerk.example", "/v1/client/sessions/session-1/tokens/t3-relay"): + body = #"{"jwt":"clerk-template-token"}"# + case ("relay.example", "/v1/environments"): + body = + #"{"environments":[{"environmentId":"mini-connect","label":"Relay mini","endpoint":{"httpBaseUrl":"https://remote.example/","wsBaseUrl":"wss://remote.example/","providerKind":"cloudflare_tunnel"},"linkedAt":"2026-07-27T00:00:00Z"}]}"# + case ("relay.example", "/v1/client/dpop-token"): + body = + #"{"access_token":"relay-access","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"DPoP","expires_in":3600,"scope":"environment:status environment:connect"}"# + case ("relay.example", "/v1/environments/mini-connect/status"): + body = + #"{"environmentId":"mini-connect","endpoint":{"httpBaseUrl":"https://remote.example/","wsBaseUrl":"wss://remote.example/","providerKind":"cloudflare_tunnel"},"status":"online","checkedAt":"2026-07-27T00:00:00Z"}"# + case ("relay.example", "/v1/environments/mini-connect/connect"): + body = + #"{"environmentId":"mini-connect","endpoint":{"httpBaseUrl":"https://remote.example/","wsBaseUrl":"wss://remote.example/","providerKind":"cloudflare_tunnel"},"credential":"environment-bootstrap","expiresAt":"2026-07-27T01:00:00Z"}"# + case ("remote.example", "/oauth/token"): + body = + #"{"access_token":"environment-access","token_type":"DPoP","expires_in":3600,"scope":"orchestration:read orchestration:operate"}"# + case ("remote.example", "/api/auth/session"): + body = #"{"authenticated":true}"# + case ("remote.example", "/.well-known/t3/environment"): + body = + #"{"environmentId":"mini-connect","label":"Relay mini","platform":{"os":"darwin","arch":"arm64"},"serverVersion":"1.2.3"}"# + default: + throw URLError(.badURL) + } + return ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: headers + )!, + Data(body.utf8) + ) + } + defer { MockRemoteURLProtocol.handler = nil } + let signer = try DPoPSigner() + let client = T3ConnectClient( + configuration: configuration, + vault: store, + signer: signer, + session: session, + requestObserver: { observed.append($0) } + ) + let environments = try await client.listEnvironments() + let environment = try #require(environments.first) + let result = try await client.connect(environment) + + #expect(result.profile.environmentID == EnvironmentID("mini-connect")) + #expect(result.profile.source == .t3Connect) + #expect(result.profile.directEndpoint?.httpBaseURL.scheme == "https") + let saved = try store.document() + #expect(saved.importedT3Connect?.clerkClientJWT == clerkClientJWT(subject: "rotated")) + #expect( + saved.connectEnvironmentCredentials["mini-connect"]?.accessToken + == "environment-access" + ) + #expect(saved.relayAccessTokens.values.first?.accessToken == "relay-access") + + let relayExchange = try #require( + observed.requests.first { $0.url?.path == "/v1/client/dpop-token" } + ) + let relayFields = formFields(try #require(relayExchange.httpBody)) + #expect(relayFields["client_id"] == "t3-web") + #expect(relayFields["scope"] == "environment:status environment:connect") + #expect(relayFields["subject_token"] == "clerk-template-token") + #expect(relayExchange.value(forHTTPHeaderField: "DPoP") != nil) + let clerkClientRequest = try #require( + network.requests.first { $0.url?.path == "/v1/client" } + ) + let clerkClientURL = try #require(clerkClientRequest.url) + let clerkQuery = try #require( + URLComponents( + url: clerkClientURL, + resolvingAgainstBaseURL: false + ) + ).queryItems ?? [] + let clerkQueryValues = Dictionary( + uniqueKeysWithValues: clerkQuery.compactMap { item in + item.value.map { (item.name, $0) } + } + ) + // Pinned to the versions in T3 Code's current Clerk/Electron client contract. + #expect(clerkQueryValues["__clerk_api_version"] == "2026-05-12") + #expect(clerkQueryValues["_clerk_js_version"] == "6.25.7") + #expect(clerkQueryValues["_is_native"] == "1") + #expect(clerkQueryValues["_electron_sdk_version"] == "0.0.18") + #expect(clerkClientRequest.value(forHTTPHeaderField: "Clerk-API-Version") == nil) + let clerkTokenRequest = try #require( + network.requests.first { + $0.url?.path == "/v1/client/sessions/session-1/tokens/t3-relay" + } + ) + #expect( + clerkTokenRequest.value(forHTTPHeaderField: "Content-Type") + == "application/x-www-form-urlencoded" + ) + let status = try #require( + observed.requests.first { + $0.url?.path == "/v1/environments/mini-connect/status" + } + ) + #expect(status.value(forHTTPHeaderField: "Authorization") == "DPoP relay-access") + #expect(status.value(forHTTPHeaderField: "DPoP") != nil) + let connect = try #require( + observed.requests.first { + $0.url?.path == "/v1/environments/mini-connect/connect" + } + ) + let connectBody = try #require(connect.httpBody) + let connectObject = try JSONSerialization.jsonObject(with: connectBody) + let connectJSON = try #require(connectObject as? [String: String]) + let thumbprint = try await signer.thumbprint() + #expect(connectJSON["clientProofKeyThumbprint"] == thumbprint) + #expect( + network.requests.contains { + $0.url?.path == "/api/auth/session" + && $0.value(forHTTPHeaderField: "Authorization") + == "DPoP environment-access" + } + ) + } + + @Test func connectRejectsInactiveClerkSessionAndPurgesOn401() async throws { + let configuration = try connectConfiguration() + var document = RemoteCredentialDocument() + document.importedT3Connect = ImportedT3ConnectCredential( + clerkClientJWT: clerkClientJWT(subject: "user"), + ciphertextFingerprint: "fingerprint" + ) + document.relayAccessTokens["old"] = RemoteAccessCredential( + accessToken: "old", + expiresAt: .distantFuture, + source: .t3Connect + ) + document.connectEnvironmentCredentials["mini"] = RemoteAccessCredential( + accessToken: "old-env", + expiresAt: .distantFuture, + source: .t3Connect + ) + let store = MemoryCredentialStore(document) + let rejectClerkClient = BooleanFlag() + let session = mockSession { request in + guard request.url?.path == "/v1/client" else { + throw URLError(.badURL) + } + let status = rejectClerkClient.isSet ? 401 : 200 + let body = rejectClerkClient.isSet + ? #"{"code":"unauthorized"}"# + : #"{"last_active_session_id":null,"sessions":[{"id":"session-1","status":"ended"}]}"# + return ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: status, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Data(body.utf8) + ) + } + defer { MockRemoteURLProtocol.handler = nil } + let client = T3ConnectClient( + configuration: configuration, + vault: store, + signer: try DPoPSigner(), + session: session + ) + do { + _ = try await client.listEnvironments() + Issue.record("Inactive sessions must be refused") + } catch T3ConnectError.invalidClerkSession { + // expected + } + // Move the Clerk client endpoint into its 401 phase. + rejectClerkClient.set() + do { + _ = try await client.listEnvironments() + Issue.record("A Clerk 401 must require import again") + } catch T3ConnectError.unauthorized { + // expected + } + let purged = try store.document() + #expect(purged.importedT3Connect == nil) + #expect(purged.relayAccessTokens.isEmpty) + #expect(purged.connectEnvironmentCredentials.isEmpty) + } + + @Test func connectUsesSoleSignedInSessionWhenLastActiveSessionIsMissing() async throws { + let configuration = try connectConfiguration() + var document = RemoteCredentialDocument() + document.importedT3Connect = ImportedT3ConnectCredential( + clerkClientJWT: clerkClientJWT(subject: "user"), + ciphertextFingerprint: "fingerprint" + ) + let store = MemoryCredentialStore(document) + let session = mockSession { request in + let path = request.url?.path ?? "" + let body: String + switch path { + case "/v1/client": + body = + #"{"response":{"last_active_session_id":null,"sessions":[{"id":"session-1","status":"active"}]}}"# + case "/v1/client/sessions/session-1/tokens/t3-relay": + body = #"{"jwt":"clerk-template-token"}"# + case "/v1/environments": + body = #"{"environments":[]}"# + default: + throw URLError(.badURL) + } + return ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Data(body.utf8) + ) + } + defer { MockRemoteURLProtocol.handler = nil } + let client = T3ConnectClient( + configuration: configuration, + vault: store, + signer: try DPoPSigner(), + session: session + ) + + #expect(try await client.listEnvironments().isEmpty) + } + + @Test func rejectedRelayTokenIsInvalidatedAndRetriedExactlyOnce() async throws { + let configuration = try connectConfiguration() + var document = RemoteCredentialDocument() + document.importedT3Connect = ImportedT3ConnectCredential( + clerkClientJWT: clerkClientJWT(subject: "user"), + ciphertextFingerprint: "fingerprint" + ) + let store = MemoryCredentialStore(document) + let exchanges = Counter() + let statuses = Counter() + let session = mockSession { request in + let path = request.url?.path ?? "" + let body: String + let statusCode: Int + switch path { + case "/v1/client": + body = + #"{"last_active_session_id":"session-1","sessions":[{"id":"session-1","status":"active"}]}"# + statusCode = 200 + case "/v1/client/sessions/session-1/tokens/t3-relay": + body = #"{"jwt":"clerk-template-token"}"# + statusCode = 200 + case "/v1/client/dpop-token": + let number = exchanges.increment() + body = + #"{"access_token":"relay-\#(number)","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"DPoP","expires_in":3600,"scope":"environment:status environment:connect"}"# + statusCode = 200 + case "/v1/environments/mini/status": + let number = statuses.increment() + statusCode = number == 1 ? 401 : 200 + body = number == 1 + ? #"{"code":"auth_invalid"}"# + : #"{"environmentId":"mini","status":"online"}"# + default: + throw URLError(.badURL) + } + return ( + HTTPURLResponse( + url: try #require(request.url), + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )!, + Data(body.utf8) + ) + } + defer { MockRemoteURLProtocol.handler = nil } + let client = T3ConnectClient( + configuration: configuration, + vault: store, + signer: try DPoPSigner(), + session: session + ) + let environment = T3ConnectEnvironment( + environmentID: EnvironmentID("mini"), + label: "Mini", + endpoint: nil, + linkedAt: nil + ) + #expect(try await client.status(environment)) + #expect(exchanges.count == 2) + #expect(statuses.count == 2) + #expect(try store.document().relayAccessTokens.values.first?.accessToken == "relay-2") + } +} + +private func mockSession( + handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) +) -> URLSession { + MockRemoteURLProtocol.handler = handler + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockRemoteURLProtocol.self] + return URLSession(configuration: configuration) +} + +private func decodeBase64URL(_ value: String) throws -> Data { + var encoded = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4) + return try #require(Data(base64Encoded: encoded)) +} + +private func jsonObject(_ encoded: String) throws -> [String: Any] { + let data = try decodeBase64URL(encoded) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) +} + +private func formFields(_ data: Data) -> [String: String] { + guard let body = String(data: data, encoding: .utf8) else { return [:] } + return Dictionary( + body.split(separator: "&").compactMap { field -> (String, String)? in + let pair = field.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard pair.count == 2 else { return nil } + func decode(_ value: Substring) -> String? { + String(value) + .replacingOccurrences(of: "+", with: " ") + .removingPercentEncoding + } + guard let name = decode(pair[0]), let value = decode(pair[1]) else { + return nil + } + return (name, value) + }, + uniquingKeysWith: { _, latest in latest } + ) +} + +private func connectConfiguration() throws -> T3ConnectConfiguration { + let frontend = Data("clerk.example$".utf8).base64URLEncodedString() + return try T3ConnectConfiguration( + clerkPublishableKey: "pk_test_\(frontend)", + clerkJWTTemplate: "t3-relay", + relayURL: try #require(URL(string: "https://relay.example/")) + ) +} + +private func clerkClientJWT(subject: String) -> String { + let header = Data(#"{"alg":"none"}"#.utf8).base64URLEncodedString() + let payload = Data( + #"{"iss":"https://clerk.example","sub":"\#(subject)"}"#.utf8 + ).base64URLEncodedString() + return "\(header).\(payload).signature" +}