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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public enum ManagedDevicePolicyKey: String, CaseIterable, Sendable {
/// listener, connection admission, and device pairing.
case disableRemoteControl = "DisableRemoteControl"

/// Disables Cloud Machines surfaces and control-plane access. This is a
/// tier-0 administrator gate: the sidebar, restored sessions, registry,
/// and CLI/socket commands must all fail closed while it is enforced.
case disableCloud = "DisableCloud"

/// Restricts embedded-browser top-level navigations to the administrator's
/// URL patterns. An empty forced array denies every external web origin
/// while preserving local `file:` documents opened through cmux's trusted
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ struct ManagedDevicePolicyTests {
// configuration profiles.
#expect(ManagedDevicePolicyKey.disableEmbeddedBrowser.rawValue == "DisableEmbeddedBrowser")
#expect(ManagedDevicePolicyKey.disableRemoteControl.rawValue == "DisableRemoteControl")
#expect(ManagedDevicePolicyKey.disableCloud.rawValue == "DisableCloud")
#expect(ManagedDevicePolicyKey.browserURLAllowlist.rawValue == "BrowserURLAllowlist")
#expect(ManagedDevicePolicy.releasePayloadDomain == "com.cmuxterm.app")
}
Expand Down
6 changes: 6 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -292978,5 +292978,11 @@
"cloudTree.link.tunnelFailed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This Mac's tunnel to your Cloud VM network could not start: %@"}},"ja":{"stringUnit":{"state":"translated","value":"この Mac からクラウド VM ネットワークへのトンネルを開始できませんでした: %@"}}}},
"cloudTree.link.tunnelOffAppManaged": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This Mac's tunnel to your Cloud VM network is down; reopen the machine to start it."}},"ja":{"stringUnit":{"state":"translated","value":"この Mac からクラウド VM ネットワークへのトンネルは切断されています。マシンを再度開くと開始されます。"}}}},
"cloudTunnel.error.configurationNotInstalled": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"The VPN configuration was not saved before the tunnel was started."}},"ja":{"stringUnit":{"state":"translated","value":"トンネルの開始前に VPN 構成が保存されていませんでした。"}}}},
"cloudTree.ports.loading": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Discovering ports…"}},"ja":{"stringUnit":{"state":"translated","value":"ポートを検出中…"}}}},
"cloudTree.ports.failed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Couldn’t discover ports. Refresh to retry."}},"ja":{"stringUnit":{"state":"translated","value":"ポートを検出できませんでした。更新して再試行してください。"}}}},
"cloudTree.ports.asleep": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Open the machine to discover ports"}},"ja":{"stringUnit":{"state":"translated","value":"マシンを開いてポートを検出"}}}},
"cloudTree.ports.unavailable": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Port discovery unavailable. Refresh to retry."}},"ja":{"stringUnit":{"state":"translated","value":"ポート検出を利用できません。更新して再試行してください。"}}}},
"cloudTree.ports.empty": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"No reachable ports"}},"ja":{"stringUnit":{"state":"translated","value":"到達可能なポートはありません"}}}},
"cloud.managed.disabled": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Cloud Machines are disabled by your administrator."}},"ja":{"stringUnit":{"state":"translated","value":"Cloud Machines は管理者によって無効になっています。"}}}},
"version": "1.0"
}
21 changes: 19 additions & 2 deletions Sources/AppDelegate+ManagedPolicy.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import Foundation
import CmuxSettings

/// Runtime enforcement for MDM managed policies (`DisableEmbeddedBrowser`,
/// `DisableRemoteControl`): installs the transition observer and closes live
/// browser panes when the browser policy activates mid-session.
/// `DisableRemoteControl`, and `DisableCloud`): installs the transition observer
/// and tears down live resources when a policy activates mid-session.
extension AppDelegate {
/// Installs the managed-policy transition observer once at startup.
func installManagedPolicyEnforcement() {
Expand All @@ -19,10 +20,26 @@ extension AppDelegate {
// policy (including live connections) and re-arms it when
// the policy lifts.
MobileHostService.shared.syncToSettings()
},
enforceCloudPolicy: { [weak self] in
guard let self, ManagedDevicePolicy().isEnforced(.disableCloud) else { return }
Task { @MainActor in
await CmuxTuiSurfaceProviderRegistry.shared.accessDidEnd()
self.closeWorkspacesForDisabledCloud()
}
}
)
}

/// A managed Cloud disable tears down providers and existing managed Cloud
/// workspaces so a policy push cannot leave an active session usable.
func closeWorkspacesForDisabledCloud() {
for manager in allTabManagersForManagedPolicyEnforcement() {
let workspaces = manager.tabs.filter(\.isManagedCloudVMWorkspace)
for workspace in workspaces { manager.closeTab(workspace) }
}
}

/// Closes every live browser pane — main area and Docks, across all
/// windows — when `DisableEmbeddedBrowser` activates while cmux runs.
func closeBrowserPanelsForManagedPolicy() {
Expand Down
40 changes: 40 additions & 0 deletions Sources/Cloud/CloudMachineSurfacePresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation

/// Immutable surface rows available even before the terminal link supplies a graph.
struct CloudMachineSurfacePresentation {
static func displays(resources: [SurfaceResource], info: SurfaceMachineInfo) -> [SurfaceResource] {
let displays = resources.filter { $0.kind == .display }
guard info.hasDesktop else { return [] }
guard displays.isEmpty else { return displays }
return [CmuxTuiSnapshotParser.display(
machine: info.id,
directURL: info.privateAddress.map { CmuxTuiSurfaceProvider.privateDesktopURL(privateAddress: $0) }
)]
}

static func emptyPorts(info: SurfaceMachineInfo) -> CloudTreeNode {
let text: String
let style: CloudTreePlaceholder.Style
switch info.linkState {
case .connecting:
text = String(localized: "cloudTree.ports.loading", defaultValue: "Discovering ports…")
style = .connecting
case .error:
text = info.linkError ?? String(localized: "cloudTree.ports.failed", defaultValue: "Couldn’t discover ports. Refresh to retry.")
style = .error
case .asleep:
text = String(localized: "cloudTree.ports.asleep", defaultValue: "Open the machine to discover ports")
style = .dimmed
case .unavailable:
text = String(localized: "cloudTree.ports.unavailable", defaultValue: "Port discovery unavailable. Refresh to retry.")
style = .dimmed
case .connected, .notApplicable:
text = String(localized: "cloudTree.ports.empty", defaultValue: "No reachable ports")
style = .dimmed
}
return CloudTreeNode(
id: "machine:\(info.id.rawValue)/ports/status",
kind: .placeholder(machine: info.id, CloudTreePlaceholder(text: text, style: style))
)
}
}
6 changes: 4 additions & 2 deletions Sources/Cloud/CloudMachinesFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ enum CloudMachinesFeature {

/// Off-main mirror for the right-sidebar mode availability path.
nonisolated static func offMainIsEnabled(defaults: UserDefaults = .standard) -> Bool {
CmuxFeatureFlags.offMainIsCloudVMUIEnabled || localOptIn(defaults: defaults)
guard !ManagedDevicePolicy().isEnforced(.disableCloud) else { return false }
return CmuxFeatureFlags.offMainIsCloudVMUIEnabled || localOptIn(defaults: defaults)
}

nonisolated static func isEnabled(defaults: UserDefaults, remoteEnabled: Bool) -> Bool {
remoteEnabled || localOptIn(defaults: defaults)
guard !ManagedDevicePolicy(defaults: defaults).isEnforced(.disableCloud) else { return false }
return remoteEnabled || localOptIn(defaults: defaults)
}

nonisolated static func localOptIn(defaults: UserDefaults) -> Bool {
Expand Down
10 changes: 6 additions & 4 deletions Sources/Cloud/CloudTreeNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -759,11 +759,13 @@ enum CloudTreeNodeBuilder {
projectionIndex: LocalProjectionIndex
) -> [CloudTreeNode] {
// The catalog has not registered this machine yet: nothing to expand.
guard let info else { return [] }
guard let info else {
return [placeholder(machine, text: String(localized: "cloudTree.placeholder.connecting", defaultValue: "Connecting…"), style: .connecting)]
}
var children: [CloudTreeNode] = []
let resources = snapshot.resources(on: machine)
let terminals = resources.filter { $0.kind == .terminal }
let displays = resources.filter { $0.kind == .display }
let displays = CloudMachineSurfacePresentation.displays(resources: resources, info: info)

switch info.linkState {
case .asleep:
Expand Down Expand Up @@ -796,11 +798,11 @@ enum CloudTreeNodeBuilder {
let right = ($1.id.forwardedPort ?? $1.port ?? 0, $1.id.key)
return left.0 != right.0 ? left.0 < right.0 : left.1 < right.1
}
if !portBrowsers.isEmpty {
do {
children.append(CloudTreeNode(
id: nodeID(portsGroup: machine),
kind: .portsGroup(machine: machine),
children: portBrowsers.map {
children: portBrowsers.isEmpty ? [CloudMachineSurfacePresentation.emptyPorts(info: info)] : portBrowsers.map {
CloudTreeNode(
id: nodeID(resource: $0.id),
kind: .port(
Expand Down
8 changes: 8 additions & 0 deletions Sources/Cloud/VMClientSocketCommands.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CmuxControlSocket
import CmuxSettings
import Foundation

extension TerminalController {
Expand All @@ -7,6 +8,13 @@ extension TerminalController {
id: Any?,
params: [String: Any]
) -> String {
if ManagedDevicePolicy().isEnforced(.disableCloud) {
return v2Error(
id: id,
code: "cloud_disabled",
message: String(localized: "cloud.managed.disabled", defaultValue: "Cloud Machines are disabled by your administrator.")
)
}
if let tunnelResponse = socketWorkerCloudTunnelResponse(method: method, id: id, params: params) {
return tunnelResponse
}
Expand Down
23 changes: 22 additions & 1 deletion Sources/ManagedPolicyEnforcementObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ final class ManagedPolicyEnforcementObserver {
private let isBrowserDisabledByPolicy: () -> Bool
private let browserURLAllowlistPolicy: () -> BrowserURLAllowlistPolicy
private let isRemoteControlDisabledByPolicy: () -> Bool
private let isCloudDisabledByPolicy: () -> Bool
private let enforceBrowserPolicy: () -> Void
private let enforceBrowserURLAllowlistPolicy: () -> Void
private let enforceRemoteControlPolicy: () -> Void
private let enforceCloudPolicy: () -> Void
private var browserPolicyActive: Bool
private var observedBrowserURLAllowlistPolicy: BrowserURLAllowlistPolicy
private var remoteControlPolicyActive: Bool
private var cloudPolicyActive: Bool
private var observationTasks: [Task<Void, Never>] = []

init(
Expand All @@ -48,20 +51,32 @@ final class ManagedPolicyEnforcementObserver {
isRemoteControlDisabledByPolicy: @escaping () -> Bool = {
MobileRemoteControlPolicy.isDisabled
},
isCloudDisabledByPolicy: @escaping () -> Bool = {
ManagedDevicePolicy().isEnforced(.disableCloud)
},
enforceBrowserPolicy: @escaping () -> Void,
enforceBrowserURLAllowlistPolicy: @escaping () -> Void,
enforceRemoteControlPolicy: @escaping () -> Void
enforceRemoteControlPolicy: @escaping () -> Void,
enforceCloudPolicy: @escaping () -> Void = {}
) {
self.notificationCenter = notificationCenter
self.isBrowserDisabledByPolicy = isBrowserDisabledByPolicy
self.browserURLAllowlistPolicy = browserURLAllowlistPolicy
self.isRemoteControlDisabledByPolicy = isRemoteControlDisabledByPolicy
self.isCloudDisabledByPolicy = isCloudDisabledByPolicy
self.enforceBrowserPolicy = enforceBrowserPolicy
self.enforceBrowserURLAllowlistPolicy = enforceBrowserURLAllowlistPolicy
self.enforceRemoteControlPolicy = enforceRemoteControlPolicy
self.enforceCloudPolicy = enforceCloudPolicy
browserPolicyActive = isBrowserDisabledByPolicy()
observedBrowserURLAllowlistPolicy = browserURLAllowlistPolicy()
remoteControlPolicyActive = isRemoteControlDisabledByPolicy()
cloudPolicyActive = isCloudDisabledByPolicy()
if cloudPolicyActive {
// A profile may already be installed before launch. Enforce it at
// startup so restored Cloud workspaces and providers are removed.
enforceCloudPolicy()
}
observe(UserDefaults.didChangeNotification)
observe(NSApplication.didBecomeActiveNotification)
observationTasks.append(Task { @MainActor [weak self] in
Expand Down Expand Up @@ -122,6 +137,12 @@ final class ManagedPolicyEnforcementObserver {
// syncToSettings() handles both teardown and re-arming.
enforceRemoteControlPolicy()
}
let cloudNow = isCloudDisabledByPolicy()
if cloudNow != cloudPolicyActive {
cloudPolicyActive = cloudNow
anyTransition = true
enforceCloudPolicy()
}
if anyTransition {
// Settings UI re-reads the resolver on this signal.
notificationCenter.post(
Expand Down
8 changes: 7 additions & 1 deletion Sources/RightSidebarPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ struct RightSidebarPanelView: View {
/// the remote host swaps files in place on one client, so a shared client
/// would make the two rails fight over one worker process.
@State private var customSidebarWorkerClient: RenderWorkerClient?
@State private var managedPolicyRevision = 0

// Re-reading the observable store inside modeBar causes SwiftUI to
// track the pending count so the badge updates live when hooks push
Expand All @@ -167,10 +168,11 @@ struct RightSidebarPanelView: View {
}

private var featureAvailableModes: [RightSidebarMode] {
_ = managedPolicyRevision
RightSidebarMode.availableModes(
feedEnabled: feedEnabled,
dockEnabled: dockEnabled,
machinesEnabled: CmuxFeatureFlags.shared.isCloudVMUIEnabled || cloudMachinesBetaEnabled
machinesEnabled: CloudMachinesFeature.isEnabled
)
}

Expand Down Expand Up @@ -269,6 +271,10 @@ struct RightSidebarPanelView: View {
.onReceive(NotificationCenter.default.publisher(for: RightSidebarTabPreferences.didChangeNotification)) { _ in
refreshModeAvailabilityAndFocusIfNeeded()
}
.onReceive(NotificationCenter.default.publisher(for: ManagedDevicePolicy.didChangeNotification)) { _ in
managedPolicyRevision &+= 1
refreshModeAvailabilityAndFocusIfNeeded()
}
}

private var modeBar: some View {
Expand Down
Loading
Loading