Fix Shared SMB Mounts on macOS Tahoe After Updates: Auto-Mount on Login Without Glitches
After a macOS Tahoe update, many admins discover their SMB shares no longer auto-mount at login, or they mount to unstable paths like /Volumes/share-1
and disappear between reboots. Finder items in “Login Items” are fragile, and scripting only mount_smbfs
ignores your Keychain (and often prompts at the worst time).
This post gives you a battle-tested fix: a tiny Python script that:
- Uses your existing Keychain credentials (no passwords in files).
- Waits for the right network, confirms the server by ARP MAC, then mounts.
- Handles Finder’s dynamic mount points by creating stable symlinks under
~/Network
. - Runs as a user LaunchAgent, so it’s reliable across reboots and sleeps.
A LaunchAgent that runs a Python script every 30 seconds (and on login). The script checks you’re on the expected subnet, confirms the target SMB server’s MAC via ARP, then mounts shares via Finder (so Keychain supplies the password). If Finder is stubborn, it falls back to
mount_smbfs
at a user-writable mount point. Finally, it drops deterministic symlinks under ~/Network
so your paths don’t change.1) One-time prep: save the credential in Keychain
- Open Finder → ⌘K (Go → Connect to Server…).
- Connect to
smb://192.168.0.1/share
(and any other share you need, e.g.scan
). - Sign in as user
user
, check “Remember this password in my keychain”. Disconnect after it mounts.
Heads-up: The script never stores your password. It relies entirely on the saved Keychain item for smb://192.168.0.1
.
2) Install the script
Create a small workspace and drop in mount_smb.py
.
# create the folders
mkdir -p /Users/user/bin /Users/user/Network
# open an editor and paste the script below
nano /Users/user/bin/mount_smb.py
# make it executable
chmod +x /Users/user/bin/mount_smb.py
mount_smb.py
Python 2/3 compatible, standard library only.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import print_function
import os, sys, time, subprocess, shlex, re, errno, datetime, socket, struct
# ------------------ CONFIG ------------------
CONFIG = {
# SMB server and shares
'SERVER_IP' : '192.168.0.1',
'SERVER_MAC' : '00:11:22:33:44:55', # any case / separators accepted
'USERNAME' : 'user',
'SHARES' : ['share', 'scan'],
# Only act when the primary IPv4 is in this subnet
'WIFI_SUBNET' : '192.168.0.0/24',
# Paths
'NETWORK_DIR' : os.path.expanduser('~/Network'),
'LOG_PATH' : os.path.expanduser('~/Library/Logs/mount_smb_python.log'),
# Timing
'ARP_RETRIES' : 10, # ARP retries (1s apart)
'MOUNT_TRIES' : 60, # osascript attempts (1s apart)
}
# --------------------------------------------
def now(): return datetime.datetime.now().strftime("[%Y-%m-%d %H:%M:%S]")
def log(msg):
line = u"{} {}".format(now(), msg)
try:
sys.stdout.write(line + "\n"); sys.stdout.flush()
except: pass
try:
with open(CONFIG['LOG_PATH'], 'a') as f: f.write(line + "\n")
except: pass
def run(cmd, shell=False):
try:
args = cmd if (shell or not isinstance(cmd, str)) else shlex.split(cmd)
p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=shell)
out, err = p.communicate(); rc = p.returncode
if not isinstance(out, str): out = out.decode('utf-8', 'ignore')
if not isinstance(err, str): err = err.decode('utf-8', 'ignore')
return rc, out, err
except OSError as e:
return 127, "", str(e)
def ip_to_int(ip): return struct.unpack("!I", socket.inet_aton(ip))[0]
def cidr_match(ip, cidr):
try:
net, pfx = cidr.split('/'); pfx = int(pfx)
mask = (0xffffffff << (32 - pfx)) & 0xffffffff
return (ip_to_int(ip) & mask) == (ip_to_int(net) & mask)
except: return False
def default_interface():
rc, out, _ = run(['/sbin/route', '-n', 'get', 'default'])
if rc != 0: return None
m = re.search(r'interface:\s+([^\s]+)', out)
return m.group(1) if m else None
def ipv4_for_iface(iface):
if not iface: return None
rc, out, _ = run(['/usr/sbin/ipconfig', 'getifaddr', iface])
ip = out.strip()
return ip if rc == 0 and re.match(r'^\d+\.\d+\.\d+\.\d+$', ip) else None
def normalize_mac(s):
if not s: return ""
s = re.sub(r'[^0-9a-f]', '', s.strip().lower())
return ':'.join( for i in range(0, len(s), 2)])
def arp_mac_for_ip(ip):
rc, out, _ = run(['/usr/sbin/arp', '-n', ip])
if rc != 0: return ""
m = re.search(r'\bat\s+([0-9a-fA-F:\- ]+)\s+on\s', out)
return normalize_mac(m.group(1)) if m else ""
def ensure_dir(path):
try: os.makedirs(path)
except OSError as e:
if e.errno != errno.EEXIST: raise
def mounted_path_for_share(server_ip, share_name):
rc, out, _ = run(['/sbin/mount'])
if rc != 0: return None
pattern = re.compile(r'@{}/{}\s+on\s+(\S+)'.format(re.escape(server_ip), re.escape(share_name)))
for line in out.splitlines():
if '@{}/{}'.format(server_ip, share_name) in line:
m = pattern.search(line)
if m: return m.group(1)
return None
def mount_via_osascript(server_ip, share_name, username):
script = u'''try
mount volume "smb://{}/{}" as user name "{}"
end try'''.format(server_ip, share_name, username)
rc, _, _ = run(['/usr/bin/osascript', '-e', script])
return rc == 0
def mount_via_mount_smbfs(server_ip, share_name, username, dest):
ensure_dir(dest)
rc, _, err = run(['/sbin/mount_smbfs', '-f', '0644', '-d', '0755',
'//{}@{}/{}'.format(username, server_ip, share_name), dest])
if rc != 0: log("mount_smbfs error for {}: rc={} err={}".format(share_name, rc, err.strip()))
return rc == 0
def stable_link(link_path, target_path):
try:
if os.path.islink(link_path) or os.path.exists(link_path):
cur = os.path.realpath(link_path)
if cur == os.path.realpath(target_path): return
try: os.remove(link_path)
except: pass
os.symlink(target_path, link_path)
except Exception as e:
log("symlink error: {} -> {} ({})".format(link_path, target_path, e))
def main():
cfg = CONFIG
ensure_dir(os.path.dirname(cfg['LOG_PATH']))
ensure_dir(cfg['NETWORK_DIR']); ensure_dir(os.path.join(cfg['NETWORK_DIR'], '.mnt'))
iface = default_interface()
ip = ipv4_for_iface(iface)
if not ip:
log("No IPv4 yet (iface={}), exiting.".format(iface)); return 0
if not cidr_match(ip, cfg['WIFI_SUBNET']):
log("IPv4 {} not in {}, exiting.".format(ip, cfg['WIFI_SUBNET'])); return 0
log("Default iface={} IPv4={} matches {} — proceeding.".format(iface, ip, cfg['WIFI_SUBNET']))
want = normalize_mac(cfg['SERVER_MAC']); mac = ""
for _ in range(cfg['ARP_RETRIES']):
mac = arp_mac_for_ip(cfg['SERVER_IP'])
if mac: break
time.sleep(1)
if want and mac and mac != want:
log("ARP MAC mismatch for {}: saw {}, expected {} — exiting.".format(cfg['SERVER_IP'], mac, want)); return 0
log("ARP {}: {}".format(cfg['SERVER_IP'], mac or "unresolved"))
for share in cfg['SHARES']:
mp = mounted_path_for_share(cfg['SERVER_IP'], share)
if mp:
log("{} already mounted at {}".format(share, mp))
stable_link(os.path.join(cfg['NETWORK_DIR'], share), mp)
continue
log("Trying to mount {} via Finder (osascript)...".format(share))
ok = False
for attempt in range(cfg['MOUNT_TRIES']):
mount_via_osascript(cfg['SERVER_IP'], share, cfg['USERNAME'])
time.sleep(1)
mp = mounted_path_for_share(cfg['SERVER_IP'], share)
if mp: ok = True; break
if (attempt + 1) % 10 == 0: log("Retry {} for {}...".format(attempt + 1, share))
if not ok:
log("Finder path did not attach; falling back to mount_smbfs for {}.".format(share))
dest = os.path.join(cfg['NETWORK_DIR'], '.mnt', share)
ok = mount_via_mount_smbfs(cfg['SERVER_IP'], share, cfg['USERNAME'], dest)
mp = dest if ok else None
if ok and mp:
log("Mounted {} at {}".format(share, mp))
stable_link(os.path.join(cfg['NETWORK_DIR'], share), mp)
else:
log("Failed to mount {}".format(share))
return 0
if __name__ == '__main__':
try: sys.exit(main())
except KeyboardInterrupt: pass
3) LaunchAgent: run it at login and when the network changes
Create a user LaunchAgent:
cat > /Users/user/Library/LaunchAgents/com.user.mountsmb.py.plist <<'EOF'
Label com.user.mountsmb.py
ProgramArguments
/opt/homebrew/bin/python3
/Users/user/bin/mount_smb.py
RunAtLoad
KeepAlive NetworkState
StartInterval 30
LimitLoadToSessionType Aqua
StandardOutPath /Users/user/Library/Logs/mount_smb_python.log
StandardErrorPath /Users/user/Library/Logs/mount_smb_python.log
EOF
chmod 644 /Users/user/Library/LaunchAgents/com.user.mountsmb.py.plist
# (Optional) Intel Homebrew path:
# /usr/local/bin/python3 /Users/user/bin/mount_smb.py
Load or reload the agent:
launchctl bootout gui/$(id -u)/com.user.mountsmb.py 2>/dev/null || true
launchctl bootstrap gui/$(id -u) /Users/user/Library/LaunchAgents/com.user.mountsmb.py.plist
launchctl kickstart -k gui/$(id -u)/com.user.mountsmb.py
4) Verify
# check logs
tail -n 80 /Users/user/Library/Logs/mount_smb_python.log
# list stable paths regardless of Finder's dynamic mount points
ls -la /Users/user/Network
ls -la /Users/user/Network/share
ls -la /Users/user/Network/scan
Troubleshooting
- “env: python: No such file or directory” — Point ProgramArguments at the full python3 path (e.g.
/opt/homebrew/bin/python3
on Apple Silicon). - Keychain prompts — Reconnect once in Finder to
smb://192.168.0.1/share
, check “Remember in Keychain”. - Wrong network — The script exits if your primary IPv4 isn’t in
192.168.0.0/24
. AdjustWIFI_SUBNET
in the config. - ARP mismatch — If your NAS has multiple NICs or failover, update
SERVER_MAC
.
Uninstall
launchctl bootout gui/$(id -u)/com.user.mountsmb.py 2>/dev/null || true
rm -f /Users/user/Library/LaunchAgents/com.user.mountsmb.py.plist
rm -f /Users/user/bin/mount_smb.py
rm -f /Users/user/Library/Logs/mount_smb_python.log
Result: your SMB shares mount cleanly after every reboot or network flap, paths stay stable via ~/Network/<share>
, and you never hard-code a secret.
GitHub code Fix SMB shared folder macOS Tahoe autostart